npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@crit-fumble/core

v10.35.0

Published

Client SDK for Crit-Fumble Core API - shared types and HTTP client

Readme

@crit-fumble/core

Official TypeScript SDK for Crit-Fumble Core API. Provides type-safe HTTP client, dice rolling utilities, and shared types for tabletop RPG applications.

Installation

npm install @crit-fumble/core

Features

  • 🎲 Dice Rolling - Advanced dice notation parser with exploding dice, advantage/disadvantage, and more
  • 🔐 Authentication - OAuth integration with Discord and GitHub
  • 📚 Knowledge Base - Access 360+ game system articles and rules references
  • 🎮 Campaign Management - Players, sessions, and message logging
  • 🗺️ CFG-X Adventure Engine - Text-based RPG with World Anvil lore integration
  • 🎯 Type Safety - Full TypeScript support with comprehensive type definitions
  • Modern - Built for ES2022+ with native ESM

Quick Start

Basic API Client

import { CoreApiClient } from '@crit-fumble/core'

const api = new CoreApiClient({
  baseUrl: process.env.CORE_API_URL || 'https://core.crit-fumble.com',
  apiKey: process.env.CORE_API_KEY
})

// Health check
const health = await api.health()
console.log(health.status) // 'ok'

Dice Rolling (v10.4.0+)

import { rollDice, validateDiceNotation } from '@crit-fumble/core'

// Basic rolls
const attack = rollDice('1d20+5')
console.log(attack.total) // 17
console.log(attack.rolls) // [12]
console.log(attack.modifier) // 5

// Advantage (D&D 5e)
const advantage = rollDice('2d20kh1+3')
console.log(advantage.output) // "2d20kh1+3: [15d, 18]+3 = 21"

// Ability score generation
const stats = rollDice('4d6d1') // Drop lowest
console.log(stats.total) // 14

// Exploding dice (Savage Worlds)
const damage = rollDice('3d6!')
console.log(damage.output) // "3d6!: [6!, 4, 5, 2] = 17"

// Validation
console.log(validateDiceNotation('2d6+3')) // true
console.log(validateDiceNotation('invalid')) // false

Supported Dice Notation

| Notation | Description | Example Output | |----------|-------------|----------------| | 2d6+3 | Basic roll with modifier | [4, 5]+3 = 12 | | 4d6d1 | Drop lowest (ability scores) | [2d, 5, 6, 4] = 15 | | 4d6dh1 | Drop highest | [6d, 3, 4, 2] = 9 | | 4d6k3 | Keep 3 highest | [5, 6, 4, 2d] = 15 | | 2d20kh1 | Advantage (keep highest) | [12d, 17] = 17 | | 2d20kl1 | Disadvantage (keep lowest) | [8, 15d] = 8 | | 3d6! | Exploding dice | [6!, 5, 4, 1] = 16 | | 2d10r1 | Reroll 1s once | [1r→7, 5] = 12 | | 1d100 | Percentile | [73] = 73 |

See Dice Notation Guide for full documentation.

API Documentation

Knowledge Base

Access game system documentation, rules, and references:

// List articles by system
const { articles } = await api.kb.list({ system: 'dnd5e' })

// Search articles
const { articles } = await api.kb.list({ search: 'dice notation' })

// Get specific article
const { article } = await api.kb.get('common/dice-notation')
console.log(article.frontmatter.title) // "Dice Notation Guide"
console.log(article.content) // Full markdown content

// Get available systems
const { systems } = await api.kb.getSystems()
// Returns: ['common', 'dnd5e', 'pf2e', 'cypher', 'foundry']

Campaign Management

Manage campaigns with players, sessions, and messages:

// List campaigns for a guild
const { campaigns } = await api.campaigns.list({ guildId: 'guild_123' })

// Create campaign
const { campaign } = await api.campaigns.create({
  name: 'Lost Mine of Phandelver',
  system: 'dnd5e',
  guildId: 'guild_123',
  status: 'active'
})

// Add player
const { player } = await api.players.add(campaign.id, {
  userId: 'user_456',
  role: 'player',
  metadata: { characterName: 'Gandalf' }
})

// Create session
const { session } = await api.sessions.create(campaign.id, {
  scheduledAt: new Date(),
  metadata: { location: 'voice-channel-789' }
})

// Log message
const { message } = await api.messages.log(session.id, {
  discordId: 'user_456',
  content: 'I cast fireball!',
  messageType: 'ic', // in-character
  metadata: { diceRoll: '8d6' }
})

Dice Roll Logging

Log dice rolls with full context for statistics and history:

// Log a dice roll (requires service auth)
const { roll } = await api.dice.log({
  notation: '2d20kh1+5',
  rolls: [12, 18],
  modifier: 5,
  total: 23,
  isCrit: false,
  isFumble: false,
  label: 'Attack Roll',
  discordId: 'user_123',
  guildId: 'guild_456',
  channelId: 'channel_789',
  source: 'discord'
})

// Get user statistics
const { stats } = await api.dice.getStats('user_123', {
  guildId: 'guild_456',
  limit: 10
})
console.log(stats.critRate) // 0.05 (5%)
console.log(stats.recentRolls) // Last 10 rolls

Container Management (v10.14.0+)

Manage Docker containers (Foundry, SteamCMD, Adventure Terminal):

// List containers
const { containers, total } = await api.container.list({
  type: 'foundry',
  status: 'running'
})

// Create container
const container = await api.container.create({
  type: 'foundry',
  userId: 'user_123',
  imageTag: 'felddy/foundryvtt:release',
  config: { license: 'ABC-123' }
})

// Start/Stop container
await api.container.start(container.id)
await api.container.stop(container.id)

// Get container stats (CPU, memory, network)
const stats = await api.container.stats(container.id)
console.log(`CPU: ${stats.cpu.percent}%, Memory: ${stats.memory.percent}%`)

// Execute command in container
const result = await api.container.exec(container.id, {
  command: ['ls', '-la', '/data']
})
console.log(result.stdout)

// File operations
const fileContent = await api.container.readFile(container.id, '/data/world.json')
await api.container.writeFile(container.id, '/data/config.json', {
  content: JSON.stringify(config),
  encoding: 'utf-8'
})
await api.container.deleteFile(container.id, '/data/old-file.txt')

// Container logs
const logs = await api.container.logs(container.id, { level: 'error', limit: 50 })
const dockerLogs = await api.container.dockerLogs(container.id, { tail: 100 })

// Templates
const { templates } = await api.container.listTemplates({ type: 'foundry' })
const newContainer = await api.container.createFromTemplate({
  templateId: 'foundry-dnd5e',
  overrides: { name: 'My Campaign Server' }
})

// Shared containers (Adventure Terminal)
const terminal = await api.container.getSharedAdventureTerminal()
const execResult = await api.container.execInSharedTerminal(['echo', 'Hello'])

FoundryVTT Integration

Manage FoundryVTT licenses and VTT container content:

// License management
const { licenses } = await api.foundry.getLicenses()
await api.foundry.linkLicense({
  licenseKey: 'FOUNDRY-LICENSE-KEY',
  ownerName: 'John Doe',
  email: '[email protected]'
})
await api.foundry.unlinkLicense('license_id')

// Container management
const { container } = await api.foundry.createContainer({
  guildId: 'guild_123',
  foundryVersion: '12',
  worldName: 'My Campaign'
})
const { containers } = await api.foundry.listContainers()
await api.foundry.stopContainer(container.containerId)

// VTT World management (v10.14.0+)
const { worlds } = await api.foundry.listWorlds(containerId)
const { world } = await api.foundry.getWorld(containerId, 'world-id')
await api.foundry.backupWorld(containerId, 'world-id')

// Module management
const { modules } = await api.foundry.listModules(containerId)
await api.foundry.installModule(containerId, 'https://manifest-url/module.json')
await api.foundry.uninstallModule(containerId, 'module-id')

// Game systems
const { systems } = await api.foundry.listSystems(containerId)

// Settings
const { settings } = await api.foundry.getSettings(containerId)
await api.foundry.updateSettings(containerId, { 'core.language': 'en' })

// Admin & Users
await api.foundry.setAdminPassword(containerId, 'new-password')
const { users } = await api.foundry.listUsers(containerId)

// License pool (admin)
const stats = await api.foundry.getPoolStats()
const { licenses } = await api.foundry.listPoolLicenses()
await api.foundry.addPoolLicense({ licenseKey: '...', notes: 'Pool license' })

Authentication

The SDK supports multiple authentication methods:

API Key (Service-to-Service)

const api = new CoreApiClient({
  baseUrl: 'https://core.crit-fumble.com',
  apiKey: process.env.CORE_API_KEY // X-API-Key header
})

Service Secret (Internal Services)

For authenticated services like FumbleBot:

const api = new CoreApiClient({
  baseUrl: 'https://core.crit-fumble.com',
  serviceSecret: process.env.CORE_SECRET, // X-Core-Secret header
  getUserContext: () => ({
    userId: currentUser.id,
    guildId: currentGuild.id,
    channelId: currentChannel.id
  })
})

Session Cookie (Web Apps)

For browser-based applications:

const api = new CoreApiClient({
  baseUrl: 'https://core.crit-fumble.com',
  credentials: 'include' // Send cookies
})

// Check auth status
const { user } = await api.auth.me()
if (user) {
  console.log(`Logged in as ${user.name}`)
}

Multi-Provider OAuth (v10.15.0+)

Core supports multiple OAuth providers. Users can link multiple accounts and sign in with any of them:

// Get available OAuth providers
const { providers } = await api.auth.providers()
// Returns: [{ id: 'discord', name: 'Discord', signinUrl: '...', ... }, ...]

// Display sign-in options
for (const provider of providers) {
  console.log(`Sign in with ${provider.name}: ${provider.signinUrl}`)
}

// Get user's linked accounts
const { accounts } = await api.auth.linkedAccounts()
for (const account of accounts) {
  console.log(`${account.provider}: ${account.providerAccountId}`)
}
// Output: discord: 123456789, github: octocat, twitch: ninja

Supported providers:

  • Discord (required) - Primary auth provider
  • GitHub (optional) - Enable with GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
  • Twitch (optional) - Enable with TWITCH_CLIENT_ID / TWITCH_CLIENT_SECRET

Account linking: When a user signs in with a new provider that has the same email as an existing account, Auth.js automatically links them. Users can then sign in with any linked provider.

Web Application Auth Proxy Setup

For Next.js applications that need to proxy authenticated requests to Core API.

1. Configure API Routes (App Router)

Create a catch-all API route to proxy requests:

// app/api/core/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server'

const CORE_API_URL = process.env.CORE_API_URL || 'https://core.crit-fumble.com'
const CORE_SECRET = process.env.CORE_SECRET

async function proxyRequest(req: NextRequest, path: string) {
  const url = `${CORE_API_URL}${path}`

  // Forward cookies for session auth
  const headers = new Headers(req.headers)
  headers.set('X-Core-Secret', CORE_SECRET || '')

  const response = await fetch(url, {
    method: req.method,
    headers,
    body: ['GET', 'HEAD'].includes(req.method) ? undefined : await req.text(),
  })

  return new NextResponse(response.body, {
    status: response.status,
    headers: response.headers,
  })
}

export async function GET(req: NextRequest, { params }: { params: { path: string[] } }) {
  const path = '/' + params.path.join('/')
  return proxyRequest(req, `/api${path}${req.nextUrl.search}`)
}

export async function POST(req: NextRequest, { params }: { params: { path: string[] } }) {
  const path = '/' + params.path.join('/')
  return proxyRequest(req, `/api${path}`)
}

export async function PUT(req: NextRequest, { params }: { params: { path: string[] } }) {
  const path = '/' + params.path.join('/')
  return proxyRequest(req, `/api${path}`)
}

export async function PATCH(req: NextRequest, { params }: { params: { path: string[] } }) {
  const path = '/' + params.path.join('/')
  return proxyRequest(req, `/api${path}`)
}

export async function DELETE(req: NextRequest, { params }: { params: { path: string[] } }) {
  const path = '/' + params.path.join('/')
  return proxyRequest(req, `/api${path}`)
}

2. Configure SDK Client

// lib/core-api.ts
import { CoreApiClient } from '@crit-fumble/core'

// Server-side: Direct connection with service auth
export const coreApiServer = new CoreApiClient({
  baseUrl: process.env.CORE_API_URL || 'https://core.crit-fumble.com',
  serviceSecret: process.env.CORE_SECRET,
})

// Client-side: Proxy through Next.js API routes
export const coreApiClient = new CoreApiClient({
  baseUrl: '/api/core', // Proxied through Next.js
  credentials: 'include',
})

3. Auth.js Adapter Integration

For SSO with Core's Auth.js database adapter:

// lib/auth.ts
import NextAuth from 'next-auth'
import Discord from 'next-auth/providers/discord'
import { CoreApiClient } from '@crit-fumble/core'

const coreApi = new CoreApiClient({
  baseUrl: process.env.CORE_API_URL!,
  serviceSecret: process.env.CORE_SECRET!,
})

// Custom adapter that proxies to Core
const CoreAdapter = {
  async createUser(user) {
    return coreApi.authAdapter.createUser(user)
  },
  async getUser(id) {
    return coreApi.authAdapter.getUser(id)
  },
  async getUserByEmail(email) {
    return coreApi.authAdapter.getUserByEmail(email)
  },
  async getUserByAccount({ provider, providerAccountId }) {
    return coreApi.authAdapter.getUserByAccount(provider, providerAccountId)
  },
  async updateUser(user) {
    return coreApi.authAdapter.updateUser(user.id, user)
  },
  async linkAccount(account) {
    return coreApi.authAdapter.linkAccount(account)
  },
  async unlinkAccount({ provider, providerAccountId }) {
    return coreApi.authAdapter.unlinkAccount(provider, providerAccountId)
  },
  async createSession(session) {
    return coreApi.authAdapter.createSession(session)
  },
  async getSessionAndUser(sessionToken) {
    return coreApi.authAdapter.getSessionAndUser(sessionToken)
  },
  async updateSession(session) {
    return coreApi.authAdapter.updateSession(session.sessionToken, session)
  },
  async deleteSession(sessionToken) {
    return coreApi.authAdapter.deleteSession(sessionToken)
  },
}

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: CoreAdapter,
  providers: [
    Discord({
      clientId: process.env.DISCORD_CLIENT_ID!,
      clientSecret: process.env.DISCORD_CLIENT_SECRET!,
    }),
  ],
})

4. Environment Variables

# Web App (.env.local)
CORE_API_URL=https://core.crit-fumble.com
CORE_SECRET=your-service-secret

# Discord OAuth
DISCORD_CLIENT_ID=your-client-id
DISCORD_CLIENT_SECRET=your-client-secret

# Auth.js
AUTH_SECRET=random-secret-for-auth-js

Discord Activity Integration

For Discord Activities using the Embedded App SDK:

import { CoreApiClient } from '@crit-fumble/core'
import { DiscordSDK } from '@discord/embedded-app-sdk'

const discordSdk = new DiscordSDK(process.env.DISCORD_CLIENT_ID!)

// Initialize SDK
await discordSdk.ready()

// Create API client with Discord context
const api = new CoreApiClient({
  baseUrl: '/.proxy', // Proxied through your activity server
  getDiscordContext: () => ({
    guildId: discordSdk.guildId,
    channelId: discordSdk.channelId,
    instanceId: discordSdk.instanceId
  })
})

// Start container for terminal activity
const { container } = await api.container.start({
  guildId: discordSdk.guildId!,
  channelId: discordSdk.channelId!
})

// Connect WebSocket terminal
const ws = new WebSocket(container.wsUrl)
ws.onmessage = (event) => {
  const { type, content } = JSON.parse(event.data)
  console.log(`[${type}]`, content)
}

// Send command
ws.send(JSON.stringify({
  type: 'command',
  content: 'roll 2d6+3'
}))

Implementation Guides

FumbleBot (Discord Bot / AI Assistant)

FumbleBot uses the SDK to interact with Core as the command layer for voice, dice, campaigns, and AI assistance.

1. Service Authentication

import { CoreApiClient } from '@crit-fumble/core'

const api = new CoreApiClient({
  baseUrl: process.env.CORE_API_URL || 'https://core.crit-fumble.com',
  serviceSecret: process.env.CORE_SECRET, // X-Core-Secret header
})

2. Voice Command WebSocket (Real-time)

Connect to Core's WebSocket to receive voice commands in real-time:

import WebSocket from 'ws'
import type { VoiceServerMessage, VoiceClientMessage } from '@crit-fumble/core'

const CORE_WS_URL = process.env.CORE_WS_URL || 'wss://core.crit-fumble.com'

// Connect with service auth
const ws = new WebSocket(
  `${CORE_WS_URL}/api/voice/ws?secret=${process.env.CORE_SECRET}&serviceId=fumblebot`
)

ws.on('open', () => {
  console.log('Connected to Core Voice WebSocket')

  // Subscribe to all guilds FumbleBot is in
  const guildIds = client.guilds.cache.map(g => g.id)
  ws.send(JSON.stringify({
    type: 'subscribe',
    guilds: guildIds
  } satisfies VoiceClientMessage))
})

ws.on('message', async (data) => {
  const message: VoiceServerMessage = JSON.parse(data.toString())

  switch (message.type) {
    case 'connected':
      console.log(`Connected as ${message.serviceId}`)
      break

    case 'subscribed':
      console.log(`Subscribed to ${message.guilds.length} guilds`)
      break

    case 'command':
      await handleVoiceCommand(message.command)
      break

    case 'pong':
      // Heartbeat response
      break
  }
})

// Handle voice commands from Activities/Web
async function handleVoiceCommand(command: VoiceCommand) {
  const { id, guildId, type, payload } = command

  try {
    switch (type) {
      case 'join':
        const channel = await client.channels.fetch(payload.channelId as string)
        await joinVoiceChannel({ channel, ... })
        break

      case 'leave':
        const connection = getVoiceConnection(guildId)
        connection?.destroy()
        break

      case 'play':
        await playAudio(guildId, payload.url as string, payload.volume as number)
        break

      case 'listen_start':
        await startListening(guildId, payload.channelId as string)
        break
    }

    // Acknowledge success
    ws.send(JSON.stringify({
      type: 'ack',
      commandId: id,
      status: 'completed'
    } satisfies VoiceClientMessage))

  } catch (error) {
    // Acknowledge failure
    ws.send(JSON.stringify({
      type: 'ack',
      commandId: id,
      status: 'failed'
    } satisfies VoiceClientMessage))
  }
}

// Keep connection alive
setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: 'ping' }))
  }
}, 30000)

3. Voice Session Reporting

Report voice state changes to Core for Activities/Web visibility:

import { Client, VoiceState } from 'discord.js'

client.on('voiceStateUpdate', async (oldState, newState) => {
  // FumbleBot joined a channel
  if (newState.member?.id === client.user?.id && newState.channel) {
    await api.voice.updateSession({
      guildId: newState.guild.id,
      channelId: newState.channel.id,
      channelName: newState.channel.name,
      participants: newState.channel.members.map(m => m.id),
      listening: false,
      playing: false
    })
  }

  // FumbleBot left a channel
  if (oldState.member?.id === client.user?.id && !newState.channel) {
    await api.voice.endSession({ guildId: oldState.guild.id })
  }
})

// Update listening/playing state
async function updateVoiceState(guildId: string, updates: Partial<VoiceSessionUpdateRequest>) {
  await api.voice.updateSession({
    guildId,
    ...updates
  })
}

4. Voice Command Polling (Alternative)

If WebSocket isn't suitable, poll for commands:

async function pollVoiceCommands(guildId: string) {
  const { commands } = await api.voice.getCommands({ guildId })

  for (const command of commands) {
    await handleVoiceCommand(command)
    await api.voice.ackCommand({
      commandId: command.id,
      status: 'completed'
    })
  }
}

// Poll every 5 seconds
setInterval(() => {
  for (const guild of client.guilds.cache.values()) {
    pollVoiceCommands(guild.id)
  }
}, 5000)

5. Dice Roll Logging

Log dice rolls for statistics and history:

import { rollDice } from '@crit-fumble/core'

async function handleDiceCommand(notation: string, context: DiscordContext) {
  const result = rollDice(notation)

  // Log to Core for stats
  await api.dice.log({
    notation: result.notation,
    rolls: result.rolls,
    modifier: result.modifier,
    total: result.total,
    isCrit: result.isCrit,
    isFumble: result.isFumble,
    label: 'User Roll',
    discordId: context.userId,
    guildId: context.guildId,
    channelId: context.channelId,
    source: 'discord'
  })

  return result
}

6. Campaign & Session Integration

Access campaign data for game sessions:

// Get active campaign for channel
const { campaigns } = await api.campaigns.list({
  guildId: interaction.guildId,
  status: 'active'
})

// Log session message
await api.messages.log(sessionId, {
  discordId: interaction.user.id,
  content: message.content,
  messageType: 'ic', // in-character
  metadata: { channelId: interaction.channelId }
})

Web Application (Activities on Web)

Serve Discord Activities both embedded in Discord and as standalone web apps.

1. Activity Server Setup (Vite/React)

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: process.env.CORE_API_URL || 'https://core.crit-fumble.com',
        changeOrigin: true,
        headers: {
          'X-Core-Secret': process.env.CORE_SECRET || ''
        }
      },
      '/.proxy': {
        target: process.env.CORE_API_URL || 'https://core.crit-fumble.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/.proxy/, ''),
        headers: {
          'X-Core-Secret': process.env.CORE_SECRET || ''
        }
      }
    }
  }
})

2. Discord Embedded App SDK Integration

// src/discord.ts
import { DiscordSDK, DiscordSDKMock } from '@discord/embedded-app-sdk'

export const isEmbedded = window.location.search.includes('frame_id')

export const discordSdk = isEmbedded
  ? new DiscordSDK(import.meta.env.VITE_DISCORD_CLIENT_ID)
  : new DiscordSDKMock(import.meta.env.VITE_DISCORD_CLIENT_ID, null, null)

export async function initializeDiscord() {
  await discordSdk.ready()

  // Authorize with Discord
  const { code } = await discordSdk.commands.authorize({
    client_id: import.meta.env.VITE_DISCORD_CLIENT_ID,
    response_type: 'code',
    state: '',
    prompt: 'none',
    scope: ['identify', 'guilds']
  })

  // Exchange code with Core for session
  const response = await fetch('/.proxy/api/activity/auth', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code })
  })

  const { access_token } = await response.json()

  // Authenticate with Discord SDK
  await discordSdk.commands.authenticate({ access_token })

  return {
    guildId: discordSdk.guildId,
    channelId: discordSdk.channelId,
    instanceId: discordSdk.instanceId
  }
}

3. Standalone Web Mode (No Discord)

// src/App.tsx
import { CoreApiClient } from '@crit-fumble/core'
import { discordSdk, isEmbedded, initializeDiscord } from './discord'

function App() {
  const [context, setContext] = useState<DiscordContext | null>(null)
  const [api, setApi] = useState<CoreApiClient | null>(null)

  useEffect(() => {
    async function init() {
      if (isEmbedded) {
        // Discord Activity mode
        const ctx = await initializeDiscord()
        setContext(ctx)
        setApi(new CoreApiClient({
          baseUrl: '/.proxy',
          getDiscordContext: () => ctx
        }))
      } else {
        // Standalone web mode - use session auth
        const client = new CoreApiClient({
          baseUrl: '/api',
          credentials: 'include'
        })

        // Check if logged in
        const { user } = await client.auth.me()
        if (!user) {
          window.location.href = '/api/auth/signin/discord'
          return
        }

        setApi(client)
      }
    }
    init()
  }, [])

  if (!api) return <div>Loading...</div>

  return <ActivityUI api={api} context={context} />
}

4. Voice Controls from Activity

Request FumbleBot to join voice from an Activity:

import type { VoiceStatusResponse } from '@crit-fumble/core'

function VoicePanel({ api, guildId }: { api: CoreApiClient, guildId: string }) {
  const [status, setStatus] = useState<VoiceStatusResponse | null>(null)

  // Poll voice status
  useEffect(() => {
    const poll = async () => {
      const status = await api.voice.getStatus(guildId)
      setStatus(status)
    }
    poll()
    const interval = setInterval(poll, 5000)
    return () => clearInterval(interval)
  }, [api, guildId])

  const handleJoin = async (channelId: string) => {
    await api.voice.join({ guildId, channelId })
    // FumbleBot will receive command via WebSocket and join
  }

  const handleLeave = async () => {
    await api.voice.leave({ guildId })
  }

  const handlePlay = async (url: string) => {
    await api.voice.play({ guildId, url, volume: 0.5 })
  }

  return (
    <div>
      {status?.connected ? (
        <>
          <p>Connected to: {status.channelName}</p>
          <p>Participants: {status.participants.length}</p>
          <button onClick={handleLeave}>Leave Voice</button>
          <button onClick={() => handlePlay('/audio/fanfare.mp3')}>Play Sound</button>
        </>
      ) : (
        <ChannelSelector onSelect={handleJoin} />
      )}
    </div>
  )
}

Container Service (VTT Management)

Manage FoundryVTT and other game containers with full lifecycle control.

1. Service Setup

import { CoreApiClient } from '@crit-fumble/core'

const api = new CoreApiClient({
  baseUrl: process.env.CORE_API_URL!,
  serviceSecret: process.env.CORE_SECRET!
})

2. Container Lifecycle

// Create a new Foundry container for a user
async function provisionFoundryServer(userId: string, guildId: string) {
  // Get user's license
  const { licenses } = await api.foundry.getLicenses()
  const license = licenses.find(l => l.userId === userId)

  if (!license) {
    throw new Error('User has no Foundry license linked')
  }

  // Create container
  const { container } = await api.foundry.createContainer({
    guildId,
    foundryVersion: '12',
    worldName: 'New Campaign'
  })

  // Wait for container to be ready
  let status = await api.container.get(container.containerId)
  while (status.status !== 'running') {
    await new Promise(r => setTimeout(r, 2000))
    status = await api.container.get(container.containerId)
  }

  return container
}

// Stop container when session ends
async function shutdownContainer(containerId: string) {
  // Create backup first
  const { worlds } = await api.foundry.listWorlds(containerId)
  for (const world of worlds) {
    await api.foundry.backupWorld(containerId, world.id)
  }

  // Stop container
  await api.container.stop(containerId)
}

3. World & Module Management

// Install modules for a game system
async function setupDnD5eWorld(containerId: string) {
  // Install core modules
  const modules = [
    'https://github.com/foundryvtt/dnd5e/releases/latest/download/module.json',
    'https://github.com/trioderegion/midi-qol/releases/latest/download/module.json'
  ]

  for (const manifest of modules) {
    await api.foundry.installModule(containerId, manifest)
  }

  // Update settings
  await api.foundry.updateSettings(containerId, {
    'core.defaultToken': { displayName: 1 },
    'dnd5e.currencyWeight': true
  })
}

// Backup world before major changes
async function safeWorldUpdate(containerId: string, worldId: string, update: () => Promise<void>) {
  // Create backup
  await api.foundry.backupWorld(containerId, worldId)

  try {
    await update()
  } catch (error) {
    // Could restore from backup if needed
    console.error('Update failed, backup available')
    throw error
  }
}

4. Container Monitoring

// Monitor container health
async function monitorContainers() {
  const { containers } = await api.container.list({ status: 'running' })

  for (const container of containers) {
    const stats = await api.container.stats(container.id)

    // Alert if resources are high
    if (stats.cpu.percent > 80) {
      console.warn(`Container ${container.id} CPU high: ${stats.cpu.percent}%`)
    }

    if (stats.memory.percent > 90) {
      console.warn(`Container ${container.id} memory high: ${stats.memory.percent}%`)
    }

    // Check for errors in logs
    const { logs } = await api.container.logs(container.id, {
      level: 'error',
      limit: 10
    })

    if (logs.length > 0) {
      console.warn(`Container ${container.id} has ${logs.length} recent errors`)
    }
  }
}

// Run every 5 minutes
setInterval(monitorContainers, 5 * 60 * 1000)

5. File Operations (Backup/Restore)

// Export world data
async function exportWorldData(containerId: string, worldId: string) {
  const worldJson = await api.container.readFile(
    containerId,
    `/data/Data/worlds/${worldId}/world.json`
  )

  return JSON.parse(worldJson)
}

// Import custom content
async function importCustomContent(containerId: string, content: object) {
  await api.container.writeFile(
    containerId,
    '/data/Data/custom-content.json',
    { content: JSON.stringify(content, null, 2) }
  )
}

6. License Pool Management (Admin)

// Manage shared license pool
async function manageLicensePool() {
  // Get pool stats
  const stats = await api.foundry.getPoolStats()
  console.log(`Pool: ${stats.available}/${stats.total} licenses available`)

  // Add license to pool
  await api.foundry.addPoolLicense({
    licenseKey: 'FOUNDRY-LICENSE-KEY',
    notes: 'Sponsored license for community servers'
  })

  // List all pool licenses
  const { licenses } = await api.foundry.listPoolLicenses()
  for (const license of licenses) {
    console.log(`${license.id}: ${license.inUse ? 'In Use' : 'Available'}`)
  }
}

CFG-X Adventure Engine (Text-Based RPG)

The CFG-X Adventure Engine is a text-based RPG system with World Anvil integration for lore management.

Architecture

Container Service (Game Engine)          Core (Data Layer + UI)
┌────────────────────────────────┐      ┌─────────────────────────────────┐
│  Adventure Terminal            │      │  /api/cfgx/*                    │
│  - Text parser & commands      │─────▶│  - Manifest (bootstrap)         │
│  - Game loop & state           │ POST │  - Characters, Locations, Items │
│  - Combat system               │      │  - Events → World Anvil timeline│
│  - NPC dialogue                │      │                                 │
└────────────────────────────────┘      │  Activity UI (Discord Embed)    │
                                        │  - Game interface               │
                                        │  - Character sheet              │
                                        └────────────┬────────────────────┘
                                                     │ Sync
                                                     ▼
                                        ┌─────────────────────────────────┐
                                        │  World Anvil                    │
                                        │  - Lore source of truth         │
                                        │  - Character stats (prototype)  │
                                        │  - Timeline events              │
                                        └─────────────────────────────────┘

1. Service Setup

import { CoreApiClient } from '@crit-fumble/core'

const api = new CoreApiClient({
  baseUrl: process.env.CORE_API_URL!,
  serviceSecret: process.env.CORE_SECRET!
})

// Universe ID for CFG-X
const CFG_X_UNIVERSE_ID = '3703992e-6090-4e5a-b4d8-5c37effdb9a8'

2. Game Engine Bootstrap (Container Service)

// Get full manifest for game initialization
const manifest = await api.cfgx.getManifest(CFG_X_UNIVERSE_ID)

console.log(`Universe: ${manifest.universe.name}`)
console.log(`Locations: ${manifest.locations.length}`)
console.log(`NPCs: ${manifest.npcs.length}`)
console.log(`Creatures: ${manifest.creatures.length}`)
console.log(`Items: ${manifest.items.length}`)
console.log(`Starting at: ${manifest.startingLocationId}`)

// Initialize game state with manifest data
const gameState = {
  locations: new Map(manifest.locations.map(l => [l.slug, l])),
  npcs: new Map(manifest.npcs.map(n => [n.slug, n])),
  creatures: new Map(manifest.creatures.map(c => [c.slug, c])),
  items: new Map(manifest.items.map(i => [i.slug, i])),
}

3. Character Management

import type {
  CfgxCharacter,
  CfgxCreateCharacterRequest,
  CfgxUpdateCharacterRequest
} from '@crit-fumble/core'

// Create new player character
const { character } = await api.cfgx.createCharacter(CFG_X_UNIVERSE_ID, {
  slug: 'player-gandalf',
  name: 'Gandalf the Grey',
  title: 'Wizard',
  characterClass: 'mystic',
  statMind: 9,
  statBody: 5,
  statSoul: 8
})

// Derived stats are calculated automatically:
// HP = body × 10 = 50
// SP = soul × 5 = 40
// Stamina = (body + mind) × 3 = 42

// Update character state during gameplay
await api.cfgx.updateCharacter(CFG_X_UNIVERSE_ID, character.id, {
  currentHp: 35,
  currentLocationId: 'tavern-entrance',
  inventory: {
    backpack: ['health-potion', 'iron-key'],
    gold: 150
  },
  equipment: {
    mainHand: 'staff-of-power',
    armor: 'grey-robes'
  },
  flags: {
    quest_innkeeper_helped: true,
    discovered_secret_passage: false
  }
})

// Get user's character
const { character: myChar } = await api.cfgx.getPlayerCharacter(
  CFG_X_UNIVERSE_ID,
  userId
)

4. Location Navigation

// Get location with exits and contents
const { location } = await api.cfgx.getLocation(CFG_X_UNIVERSE_ID, 'tavern-entrance')

console.log(location.name) // "The Rusty Tankard"
console.log(location.description) // Full room description
console.log(location.exits) // { north: 'market-square', east: 'tavern-bar', down: 'cellar' }
console.log(location.npcIds) // ['innkeeper-bob', 'mysterious-stranger']
console.log(location.itemIds) // ['notice-board', 'dusty-chest']

// List all locations with filters
const { locations, total } = await api.cfgx.listLocations(CFG_X_UNIVERSE_ID, {
  locationType: 'room',
  limit: 50
})

5. World Anvil Integration

CFG-X character stats are stored in World Anvil's characterPrototype field as JSON:

import type {
  CfgxSetCharacterStatsRequest,
  CfgxWorldAnvilArticle
} from '@crit-fumble/core'

// Set character stats directly in World Anvil
// Stats format: {"mind": 7, "body": 5, "soul": 8, "class": "mystic", "level": 3}
await api.cfgx.setCharacterStats(
  CFG_X_UNIVERSE_ID,
  '388f860b-a308-40db-a5d7-4d2201d4e2ff', // World Anvil article ID
  {
    mind: 7,
    body: 5,
    soul: 8,
    class: 'mystic',
    level: 3,
    xp: 1500
  }
)

// Get World Anvil article details
const { article } = await api.cfgx.getWorldAnvilArticle(
  CFG_X_UNIVERSE_ID,
  '388f860b-a308-40db-a5d7-4d2201d4e2ff'
)
console.log(article.title) // "Fumble"
console.log(article.characterPrototype) // '{"mind":7,"body":5,"soul":8,...}'

// List all World Anvil articles in universe
const { articles, count } = await api.cfgx.listWorldAnvilArticles(CFG_X_UNIVERSE_ID)
for (const a of articles) {
  console.log(`${a.entityClass}: ${a.title} (${a.id})`)
}
// Output: person: Fumble (388f860b-...), location: Tavern (abc123-...)

6. Content Sync (Admin)

// Pull content from World Anvil → Core database
const pullResult = await api.cfgx.syncPull(CFG_X_UNIVERSE_ID)
console.log(`Synced: ${pullResult.summary.totalSynced} items`)
console.log(`Errors: ${pullResult.summary.totalErrors}`)
console.log(pullResult.results)
// {
//   locations: { synced: 15, errors: 0 },
//   characters: { synced: 8, errors: 1 },
//   creatures: { synced: 12, errors: 0 },
//   items: { synced: 45, errors: 0 }
// }

// Push game events to World Anvil timeline
const pushResult = await api.cfgx.syncPush(CFG_X_UNIVERSE_ID)
console.log(`Pushed ${pushResult.pushed} events to timeline`)

7. Game Events

import type { CfgxCreateEventRequest, CfgxEventType } from '@crit-fumble/core'

// Log significant game events (synced to World Anvil timeline)
await api.cfgx.createEvent(CFG_X_UNIVERSE_ID, {
  eventType: 'boss_kill',
  title: 'The Dragon Falls',
  description: 'Gandalf defeated the ancient wyrm in single combat.',
  characterId: character.id,
  locationId: 'dragon-lair',
  locationName: 'The Dragon\'s Lair',
  isPublic: true,
  isCanon: true,
  metadata: {
    bossName: 'Smaug the Terrible',
    lootObtained: ['dragon-scale-armor', 'hoard-gold']
  }
})

// Event types: quest_complete, boss_kill, discovery, death, level_up, achievement

8. Stat Formulas

CFG-X uses a simple 3-stat system (1-10 scale):

| Stat | Description | Derived Values | |------|-------------|----------------| | Mind | Intelligence, perception, magic | - | | Body | Strength, endurance, physical | HP = body × 10 | | Soul | Charisma, willpower, spirit | SP = soul × 5 | | - | - | Stamina = (body + mind) × 3 |

Character classes: explorer, scholar, warrior, mystic


Discord Activity Auth Keep-Alive (Web)

When serving Discord Activities on the web (outside Discord client), sessions need to be kept alive to prevent logout during gameplay.

1. Auth Flow for Web Activities

// src/hooks/useActivityAuth.ts
import { useEffect, useRef, useState } from 'react'
import { CoreApiClient } from '@crit-fumble/core'

export function useActivityAuth() {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)
  const keepAliveRef = useRef<NodeJS.Timeout>()

  useEffect(() => {
    const api = new CoreApiClient({
      baseUrl: '/api',
      credentials: 'include'
    })

    async function initAuth() {
      try {
        // Check if already logged in
        const { user } = await api.auth.me()

        if (user) {
          setUser(user)
          startKeepAlive(api)
        } else {
          // Redirect to Discord OAuth
          window.location.href = '/api/auth/signin/discord?callbackUrl=' +
            encodeURIComponent(window.location.pathname)
        }
      } catch (error) {
        console.error('Auth check failed:', error)
      } finally {
        setLoading(false)
      }
    }

    function startKeepAlive(api: CoreApiClient) {
      // Ping session every 5 minutes to prevent expiry
      keepAliveRef.current = setInterval(async () => {
        try {
          await api.auth.session()
        } catch (error) {
          console.error('Session keep-alive failed:', error)
          // Session expired, trigger re-auth
          setUser(null)
        }
      }, 5 * 60 * 1000) // 5 minutes
    }

    initAuth()

    return () => {
      if (keepAliveRef.current) {
        clearInterval(keepAliveRef.current)
      }
    }
  }, [])

  return { user, loading }
}

2. Activity Route Handler (Next.js)

// app/activity/[...path]/page.tsx
import { fetchCoreSession } from '@crit-fumble/core'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'

export default async function ActivityPage({ params }: { params: { path: string[] } }) {
  const cookieStore = cookies()
  const session = await fetchCoreSession(process.env.CORE_API_URL!, {
    secret: process.env.CORE_SECRET!,
    cookie: cookieStore.toString()
  })

  if (!session.user) {
    redirect('/api/auth/signin/discord?callbackUrl=/activity/' + params.path.join('/'))
  }

  return <ActivityClient userId={session.user.id} />
}

3. Session Touch Endpoint

Core automatically extends sessions on access. For long-running activities, periodically call any authenticated endpoint:

// Lightweight session touch - just checks session validity
const response = await fetch('/api/auth/session', {
  credentials: 'include'
})

if (!response.ok) {
  // Session expired, need to re-authenticate
  window.location.href = '/api/auth/signin/discord'
}

4. Cross-Origin Activity Setup

For activities served from a different domain:

// vite.config.ts (Activity dev server)
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://core.crit-fumble.com',
        changeOrigin: true,
        cookieDomainRewrite: 'localhost',
        headers: {
          'X-Core-Secret': process.env.CORE_SECRET || ''
        }
      }
    }
  }
})

Production activities should be served from *.crit-fumble.com to share the auth cookie domain.


Environment Variables Summary

# All Services
CORE_API_URL=https://core.crit-fumble.com
CORE_SECRET=your-service-secret

# FumbleBot Additional
CORE_WS_URL=wss://core.crit-fumble.com
DISCORD_TOKEN=your-discord-bot-token

# Web App Additional
VITE_DISCORD_CLIENT_ID=your-discord-client-id
AUTH_SECRET=random-auth-secret

# Container Service Additional
DOCKER_HOST=unix:///var/run/docker.sock

Response Signing (Service-to-Service Security)

Core signs certain responses with HMAC-SHA256 for authenticity verification. This prevents man-in-the-middle attacks and ensures response integrity.

How It Works

  1. Request: Service sends X-Core-Secret header to prove identity
  2. Response: Core signs the response body and includes X-Response-Signature header
  3. Verify: Service verifies signature matches using the same secret

Quick Start with SDK

import { fetchCoreSession, signedFetch, verifyResponseSignature } from '@crit-fumble/core'

// Option 1: Use the convenience function for sessions
const session = await fetchCoreSession(process.env.CORE_API_URL!, {
  secret: process.env.CORE_SECRET!,
  cookie: request.headers.get('cookie') || undefined
})

if (session.user) {
  console.log(`Authenticated as ${session.user.name}`)
}

// Option 2: Use signedFetch for any endpoint
const { data, verified } = await signedFetch<{ campaigns: Campaign[] }>(
  `${process.env.CORE_API_URL}/api/campaigns`,
  {
    secret: process.env.CORE_SECRET!,
    method: 'GET'
  }
)

console.log(`Response verified: ${verified}`)

Manual Verification

import { verifyResponseSignature } from '@crit-fumble/core'

// Make request with X-Core-Secret header
const response = await fetch(`${CORE_API_URL}/api/auth/session`, {
  headers: {
    'X-Core-Secret': process.env.CORE_SECRET!,
    'Cookie': request.headers.get('cookie') || ''
  }
})

// Get signature and body
const signature = response.headers.get('X-Response-Signature')
const body = await response.text()

// Verify signature
const isValid = verifyResponseSignature(body, signature, process.env.CORE_SECRET!)

if (!isValid) {
  throw new Error('Response signature verification failed - possible tampering')
}

// Safe to parse
const data = JSON.parse(body)

Next.js Middleware Example

// middleware.ts
import { fetchCoreSession } from '@crit-fumble/core'
import { NextRequest, NextResponse } from 'next/server'

export async function middleware(request: NextRequest) {
  // Verify session with Core (signature verified automatically)
  const session = await fetchCoreSession(process.env.CORE_API_URL!, {
    secret: process.env.CORE_SECRET!,
    cookie: request.headers.get('cookie') || undefined
  })

  // Redirect to login if not authenticated
  if (!session.user) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // Add user info to request headers for downstream use
  const response = NextResponse.next()
  response.headers.set('x-user-id', session.user.id)
  response.headers.set('x-user-admin', String(session.user.isAdmin))

  return response
}

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*']
}

Express/Node.js Middleware Example

// middleware/auth.ts
import { fetchCoreSession } from '@crit-fumble/core'
import type { Request, Response, NextFunction } from 'express'

export async function requireAuth(req: Request, res: Response, next: NextFunction) {
  try {
    const session = await fetchCoreSession(process.env.CORE_API_URL!, {
      secret: process.env.CORE_SECRET!,
      cookie: req.headers.cookie
    })

    if (!session.user) {
      return res.status(401).json({ error: 'Not authenticated' })
    }

    // Attach user to request
    req.user = session.user
    next()
  } catch (error) {
    console.error('Auth verification failed:', error)
    return res.status(500).json({ error: 'Authentication error' })
  }
}

// Usage
app.get('/api/protected', requireAuth, (req, res) => {
  res.json({ message: `Hello ${req.user.name}` })
})

Creating Signatures (For Core API Development)

import { createResponseSignature } from '@crit-fumble/core'

// Sign a response body
const body = JSON.stringify({ user: userData, expires: session.expires })
const signature = createResponseSignature(body, process.env.CORE_SECRET!)

// Set header
response.setHeader('X-Response-Signature', signature)
response.send(body)

Security Notes

  • Timing-safe comparison: The SDK uses crypto.timingSafeEqual to prevent timing attacks
  • HMAC-SHA256: Industry-standard algorithm for message authentication
  • Shared secret: Both services must have the same CORE_SECRET value
  • Signature scope: Currently applied to /api/auth/session endpoint
  • Optional verification: If no signature header is present, the request still works but verified will be false

Type-Safe API

All responses are fully typed:

import type {
  Campaign,
  Character,
  User,
  DiceRollResult,
  KBArticle
} from '@crit-fumble/core'

// Type-safe campaign data
const campaign: Campaign = await api.campaigns.get(id)
campaign.name // string
campaign.status // 'active' | 'completed' | 'on_hold'
campaign.system // 'dnd5e' | 'pf2e' | 'cypher' | ...

// Type-safe dice rolls
const roll: DiceRollResult = rollDice('2d20kh1')
roll.notation // string
roll.rolls // number[]
roll.total // number
roll.isCrit // boolean

Error Handling

import { HttpError } from '@crit-fumble/core'

try {
  const { campaign } = await api.campaigns.get('invalid-id')
} catch (error) {
  if (error instanceof HttpError) {
    console.error(`API Error ${error.status}:`, error.message)
    console.error('Response:', error.response)
  }
}

Rate Limiting

The API includes rate limiting with headers:

const response = await api.campaigns.list()
console.log(response.headers['x-ratelimit-limit']) // "100"
console.log(response.headers['x-ratelimit-remaining']) // "95"
console.log(response.headers['x-ratelimit-reset']) // Unix timestamp

Advanced Usage

Custom HTTP Client

import { HttpClient } from '@crit-fumble/core/client'

const http = new HttpClient({
  baseUrl: 'https://core.crit-fumble.com',
  headers: {
    'X-API-Key': process.env.CORE_API_KEY
  },
  timeout: 10000
})

const { data } = await http.get('/api/campaigns', {
  params: { guildId: 'guild_123' }
})

Type Imports Only

For type-only usage (no runtime code):

import type { Campaign, User, DiceRollResult } from '@crit-fumble/core/types'

function processCampaign(campaign: Campaign): void {
  console.log(campaign.name)
}

Environment Variables

# Required
CORE_API_URL=https://core.crit-fumble.com

# For service authentication
CORE_API_KEY=your-api-key-here

# For internal services
CORE_SECRET=your-service-secret-here

# For Discord Activities
DISCORD_CLIENT_ID=your-discord-client-id

Browser Support

  • Chrome/Edge 90+
  • Firefox 88+
  • Safari 14+
  • Node.js 18+

Related Packages

| Package | Description | |---------|-------------| | @crit-fumble/core | Main SDK (this package) | | @crit-fumble/react | React components library | | @crit-fumble/fumblebot-proxy | Express proxy middleware |

Changelog

v10.31.0 (2025-12-15)

  • CFG-X Adventure Engine - Text-based RPG API with World Anvil integration
    • cfgx.getManifest() - Bootstrap game engine with full universe data
    • cfgx.listLocations() / getLocation() - Navigate game world
    • cfgx.createCharacter() / updateCharacter() - Player character management
    • cfgx.listCreatures() / listItems() - Game content access
    • cfgx.createEvent() - Log game events for World Anvil timeline
    • cfgx.syncPull() / syncPush() - Bi-directional World Anvil sync
  • World Anvil Direct Integration - Programmatic lore management
    • cfgx.setCharacterStats() - Set CFG-X stats on WA Person articles
    • cfgx.getWorldAnvilArticle() - Retrieve article details
    • cfgx.listWorldAnvilArticles() - List all articles in universe
    • Stats stored in characterPrototype field as JSON
  • Activity Auth Keep-Alive - Web activity session management documentation
    • Session keep-alive hook for React apps
    • Next.js route handler examples
    • Cross-origin activity setup guide

v10.20.0 (2025-12-12)

  • 🔐 Response Signing - HMAC-SHA256 signature verification for service-to-service security
    • verifyResponseSignature(body, signature, secret) - Verify response authenticity
    • createResponseSignature(body, secret) - Create HMAC-SHA256 signature
    • signedFetch(url, options) - Fetch with automatic signature verification
    • fetchCoreSession(baseUrl, options) - Convenience function for session endpoint
  • 🛡️ Security Features:
    • Timing-safe comparison to prevent timing attacks
    • X-Response-Signature header on /api/auth/session responses
    • X-Core-Secret header verification for service identity
  • 📖 Implementation Guides:
    • Next.js middleware example
    • Express/Node.js middleware example
    • Manual verification example

v10.18.0 (2025-12-12)

  • 🎤 Voice Session Tracking - Full voice state management
    • voice.updateSession() - Report voice state from FumbleBot
    • voice.endSession() - End voice session when leaving
    • voice.getStatus() - Get voice status for a guild
    • voice.getSessions() - List all active voice sessions
  • 🔊 Voice Commands - Request voice actions from Activities/Web
    • voice.join() - Request FumbleBot join a voice channel
    • voice.leave() - Request FumbleBot leave voice
    • voice.play() - Request audio playback
    • voice.stop() - Stop audio playback
    • voice.listenStart() / voice.listenStop() - Wake word listening
  • 🌐 Voice WebSocket - Real-time command delivery
    • wss://core.crit-fumble.com/api/voice/ws - WebSocket endpoint
    • Subscribe to guilds, receive commands in real-time
    • Acknowledge command completion/failure
    • Types: VoiceServerMessage, VoiceClientMessage, VoiceCommand
  • 📝 Voice Transcripts - Log voice-to-text transcriptions
    • voice.logTranscript() - Log a transcription
    • voice.getTranscripts() - Retrieve transcripts
  • 📖 Implementation Guides - Comprehensive setup documentation
    • FumbleBot: WebSocket integration, voice reporting, command handling
    • Web: Discord Activity + standalone web mode
    • Container Service: VTT lifecycle, monitoring, file operations

v10.15.0 (2025-12-09)

  • 🔐 Multi-Provider OAuth - Support for multiple OAuth providers
    • Added Twitch OAuth provider (Discord, GitHub, Twitch now supported)
    • auth.providers() - Get list of available OAuth providers
    • auth.linkedAccounts() - Get user's linked OAuth accounts
    • Automatic account linking when users sign in with matching emails
  • 📖 New endpoints for web client auth integration:
    • GET /api/auth/providers - List available OAuth providers
    • GET /api/auth/me/accounts - List user's linked OAuth accounts

v10.16.0 (2025-12-09)

  • 🔐 Cross-Subdomain Auth - Session sharing across *.crit-fumble.com
    • Cookies now set with domain=.crit-fumble.com in production
    • www.crit-fumble.com can validate sessions from core.crit-fumble.com
  • New Session API for web apps:
    • auth.getSigninUrl(provider, callbackUrl) - Get OAuth signin URL
    • auth.session() - Get current session (returns user with discordId, isAdmin)
    • auth.signout() - Destroy session and clear cookie
  • 📖 New endpoints:
    • GET /api/auth/session - Get session with full user info
    • POST /api/auth/signout - Sign out and clear cookie

v10.14.2 (2025-12-09)

  • 🔒 Security: Added HSTS header (Strict-Transport-Security) for production
    • Enforces HTTPS for 1 year with includeSubDomains
    • Protects against protocol downgrade attacks

v10.14.0 (2025-12-09)

  • Container API - Full container orchestration
    • stats() - Get CPU, memory, network statistics
    • logs() / dockerLogs() - Application and Docker logs
    • exec() - Execute commands in containers
    • readFile() / writeFile() / deleteFile() - File operations
    • listTemplates() / createFromTemplate() - Template management
    • listShared() / getSharedAdventureTerminal() - Shared containers
  • Foundry VTT Management - Full VTT control
    • listWorlds() / getWorld() / backupWorld() - World management
    • listModules() / installModule() / uninstallModule() - Module management
    • listSystems() - Game system listing
    • getSettings() / updateSettings() - Settings management
    • setAdminPassword() / listUsers() - Admin operations
  • 📖 Added Web Application Auth Proxy setup documentation

v10.4.0 (2025-12-02)

  • ✨ Added dice rolling utilities with @dice-roller/rpg-dice-roller
  • 📚 rollDice() - Roll dice with advanced notation support
  • validateDiceNotation() - Validate notation without rolling
  • 📖 Updated KB with corrected dice notation syntax

v10.3.0

  • ✨ Added FoundryVTT container management API
  • 🔧 Improved TypeScript types for campaigns

v10.2.0

  • ✨ Added Knowledge Base API (362 articles)
  • ✨ Added Campaign Management API
  • ✨ Added Players, Sessions, and Messages APIs

v10.1.0

  • ✨ Auth-agnostic dice roll logging
  • 🔧 Support for both userId and discordId

Support

License

MIT © Crit-Fumble