/ src / lib / server / sharing / share-resolver.ts
share-resolver.ts
  1  import type { ShareEntityType, ShareLink } from './share-link-repository'
  2  import { loadStoredItem } from '@/lib/server/storage'
  3  import { listMissionReports } from '@/lib/server/missions/mission-repository'
  4  
  5  export interface SharedMissionPayload {
  6    kind: 'mission'
  7    id: string
  8    title: string
  9    goal: string
 10    successCriteria: string[]
 11    status: string
 12    createdAt: number
 13    milestones: Array<{ at: number; note: string; kind: string }>
 14    reports: Array<{ at: number; format: string; content: string }>
 15  }
 16  
 17  export interface SharedSkillPayload {
 18    kind: 'skill'
 19    id: string
 20    name: string
 21    description: string
 22    tags: string[]
 23    content: string
 24    sourceFormat: string | null
 25    createdAt: number | null
 26  }
 27  
 28  export interface SharedSessionPayload {
 29    kind: 'session'
 30    id: string
 31    name: string
 32    agentName: string | null
 33    messages: Array<{ role: string; text: string; at: number | null }>
 34    createdAt: number
 35  }
 36  
 37  export type SharedPayload = SharedMissionPayload | SharedSkillPayload | SharedSessionPayload
 38  
 39  const MAX_MESSAGES = 60
 40  const MAX_MILESTONES = 40
 41  const MAX_REPORTS = 10
 42  
 43  export function resolveSharedEntity(link: ShareLink): SharedPayload | null {
 44    switch (link.entityType) {
 45      case 'mission':
 46        return resolveMission(link.entityId)
 47      case 'skill':
 48        return resolveSkill(link.entityId)
 49      case 'session':
 50        return resolveSession(link.entityId)
 51      default:
 52        return null
 53    }
 54  }
 55  
 56  function resolveMission(id: string): SharedMissionPayload | null {
 57    const raw = loadStoredItem('agent_missions', id) as Record<string, unknown> | null
 58    if (!raw) return null
 59    const milestonesRaw = Array.isArray(raw.milestones) ? raw.milestones : []
 60    const milestones = milestonesRaw
 61      .slice(-MAX_MILESTONES)
 62      .map((m) => {
 63        const entry = (m || {}) as Record<string, unknown>
 64        return {
 65          at: typeof entry.at === 'number' ? entry.at : 0,
 66          note: typeof entry.note === 'string' ? entry.note : '',
 67          kind: typeof entry.kind === 'string' ? entry.kind : 'note',
 68        }
 69      })
 70  
 71    let reports: SharedMissionPayload['reports'] = []
 72    try {
 73      const rows = listMissionReports(id, MAX_REPORTS)
 74      reports = rows.map((r) => ({
 75        at: r.generatedAt,
 76        format: String(r.format),
 77        content: r.body,
 78      }))
 79    } catch {
 80      reports = []
 81    }
 82  
 83    return {
 84      kind: 'mission',
 85      id,
 86      title: typeof raw.title === 'string' ? raw.title : 'Untitled Mission',
 87      goal: typeof raw.goal === 'string' ? raw.goal : '',
 88      successCriteria: Array.isArray(raw.successCriteria)
 89        ? (raw.successCriteria as unknown[]).filter((x): x is string => typeof x === 'string')
 90        : [],
 91      status: typeof raw.status === 'string' ? raw.status : 'unknown',
 92      createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : 0,
 93      milestones,
 94      reports,
 95    }
 96  }
 97  
 98  function resolveSkill(id: string): SharedSkillPayload | null {
 99    const raw = loadStoredItem('skills', id) as Record<string, unknown> | null
100    if (!raw) return null
101    return {
102      kind: 'skill',
103      id,
104      name: typeof raw.name === 'string' ? raw.name : 'Unnamed Skill',
105      description: typeof raw.description === 'string' ? raw.description : '',
106      tags: Array.isArray(raw.tags)
107        ? (raw.tags as unknown[]).filter((x): x is string => typeof x === 'string')
108        : [],
109      content: typeof raw.content === 'string' ? raw.content : '',
110      sourceFormat: typeof raw.sourceFormat === 'string' ? raw.sourceFormat : null,
111      createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : null,
112    }
113  }
114  
115  function resolveSession(id: string): SharedSessionPayload | null {
116    const raw = loadStoredItem('sessions', id) as Record<string, unknown> | null
117    if (!raw) return null
118    const messagesRaw = Array.isArray(raw.messages) ? raw.messages : []
119    const messages = messagesRaw.slice(-MAX_MESSAGES).map((m) => {
120      const entry = (m || {}) as Record<string, unknown>
121      return {
122        role: typeof entry.role === 'string' ? entry.role : 'unknown',
123        text: typeof entry.content === 'string' ? entry.content : '',
124        at: typeof entry.at === 'number' ? entry.at : null,
125      }
126    })
127  
128    let agentName: string | null = null
129    const agentId = typeof raw.agentId === 'string' ? raw.agentId : null
130    if (agentId) {
131      const agent = loadStoredItem('agents', agentId) as Record<string, unknown> | null
132      if (agent && typeof agent.name === 'string') agentName = agent.name
133    }
134  
135    return {
136      kind: 'session',
137      id,
138      name: typeof raw.name === 'string' ? raw.name : 'Untitled Session',
139      agentName,
140      messages,
141      createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : 0,
142    }
143  }
144  
145  /**
146   * Shape enforced on every outbound shared payload: fields that should never
147   * leak off-instance. Reasons kept on the function to keep the allowlist obvious.
148   */
149  export const SHARE_ALLOWED_ENTITY_TYPES: readonly ShareEntityType[] = [
150    'mission',
151    'skill',
152    'session',
153  ] as const