From 16efca21109bc2cb14ddc0155611d0de5896fc00 Mon Sep 17 00:00:00 2001 From: Alex Franzen Date: Thu, 5 Feb 2026 11:16:39 -0500 Subject: [PATCH] feat: Strengthen .glance.json AI generation requirements Three-layer enforcement for widget definitions: 1. **Stricter TypeScript Types** - Made `data_schema` required (non-nullable) in CustomWidget interface - Made `credentials` and `data_schema` required in DashboardExportFormat - Made `data_schema` required in WidgetPackage interface 2. **JSON Schema for Structured Output** (NEW: docs/schemas/widget-schema.json) - Comprehensive schema for AI structured output modes (Anthropic tool_use, OpenAI response_format) - Enforces all required fields at generation time - Conditional requirements (fetch.instructions/schedule required for agent_refresh) - data_schema.required must include "fetchedAt" 3. **Enhanced Validation** - validateDashboardFormat() now validates: data_schema structure, fetch config, size constraints, credentials array, and slug format - validateWidgetPackage() now validates data_schema requirements - Return type includes warnings array for non-critical issues Also updated SKILL.md with: - "AI Structured Output Generation (REQUIRED)" section - Required fields checklist table - Minimal valid widget example Co-Authored-By: Claude Opus 4.5 --- SKILL.md | 74 ++++- docs/schemas/widget-schema.json | 431 ++++++++++++++++++++++++++ src/app/api/dashboard/export/route.ts | 4 +- src/app/api/dashboard/import/route.ts | 4 +- src/lib/dashboard-format.ts | 168 +++++++++- src/lib/db.ts | 10 +- src/lib/widget-package.ts | 25 +- 7 files changed, 688 insertions(+), 28 deletions(-) create mode 100644 docs/schemas/widget-schema.json diff --git a/SKILL.md b/SKILL.md index fcdf5f8..183153a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -36,6 +36,60 @@ curl -X POST "$GLANCE_URL/api/widgets/my-widget/cache" \ browser action:open targetUrl:"$GLANCE_URL" ``` +## AI Structured Output Generation (REQUIRED) + +When generating widget definitions, **use the JSON Schema** at `docs/schemas/widget-schema.json` with your AI model's structured output mode: +- **Anthropic**: Use `tool_use` with the schema +- **OpenAI**: Use `response_format: { type: "json_schema", schema }` + +The schema enforces all required fields at generation time — malformed widgets cannot be produced. + +### Required Fields Checklist +Every widget **MUST** have these fields (the schema enforces them): + +| Field | Type | Notes | +|-------|------|-------| +| `name` | string | Non-empty, human-readable | +| `slug` | string | Lowercase kebab-case (`my-widget`) | +| `source_code` | string | Valid JSX with Widget function | +| `default_size` | `{ w: 1-12, h: 1-20 }` | Grid units | +| `min_size` | `{ w: 1-12, h: 1-20 }` | Cannot resize smaller | +| `fetch.type` | enum | `"server_code"` \| `"webhook"` \| `"agent_refresh"` | +| `fetch.instructions` | string | **REQUIRED if type is `agent_refresh`** | +| `fetch.schedule` | string | **REQUIRED if type is `agent_refresh`** (cron) | +| `data_schema.type` | `"object"` | Always object | +| `data_schema.properties` | object | Define each field | +| `data_schema.required` | array | **MUST include `"fetchedAt"`** | +| `credentials` | array | Use `[]` if none needed | + +### Example: Minimal Valid Widget + +```json +{ + "name": "My Widget", + "slug": "my-widget", + "source_code": "function Widget({ serverData }) { return
{serverData?.value}
; }", + "default_size": { "w": 2, "h": 2 }, + "min_size": { "w": 1, "h": 1 }, + "fetch": { + "type": "agent_refresh", + "schedule": "*/15 * * * *", + "instructions": "## Data Collection\nCollect the data...\n\n## Cache Update\nPOST to /api/widgets/my-widget/cache" + }, + "data_schema": { + "type": "object", + "properties": { + "value": { "type": "number" }, + "fetchedAt": { "type": "string", "format": "date-time" } + }, + "required": ["value", "fetchedAt"] + }, + "credentials": [] +} +``` + +--- + ## ⚠️ Widget Creation Checklist (MANDATORY) Every widget must complete ALL steps before being considered done: @@ -731,15 +785,17 @@ To summarize dashboard for user: ## ⚠️ Rules & Gotchas -1. **Browser verify EVERYTHING** — don't report success until you see the widget render correctly -2. **agent_refresh = YOU collect data** — the widget just displays what you POST to cache -3. **fetch.instructions is the source of truth** — cron jobs just send the slug, you look up instructions -4. **Always include fetchedAt** — widgets need timestamps for "Updated X ago" display -5. **data_schema is required** — cache POSTs validate against it, malformed data returns 400 -6. **Don't wrap in Card** — the framework provides the outer card, you render content only -7. **Use Haiku for refresh subagents** — mechanical data collection doesn't need Opus -8. **Mark refresh requests as processed** — `DELETE /api/widgets/{slug}/refresh` after handling -9. **Spawn subagents for refreshes** — don't block main session with PTY/long-running work +1. **Use JSON Schema for generation** — `docs/schemas/widget-schema.json` enforces all required fields +2. **Browser verify EVERYTHING** — don't report success until you see the widget render correctly +3. **agent_refresh = YOU collect data** — the widget just displays what you POST to cache +4. **fetch.instructions is the source of truth** — cron jobs just send the slug, you look up instructions +5. **Always include fetchedAt** — widgets need timestamps for "Updated X ago" display +6. **data_schema is REQUIRED** — cache POSTs validate against it, malformed data returns 400 +7. **credentials is REQUIRED** — use empty array `[]` if no credentials needed +8. **Don't wrap in Card** — the framework provides the outer card, you render content only +9. **Use Haiku for refresh subagents** — mechanical data collection doesn't need Opus +10. **Mark refresh requests as processed** — `DELETE /api/widgets/{slug}/refresh` after handling +11. **Spawn subagents for refreshes** — don't block main session with PTY/long-running work ## Environment Variables diff --git a/docs/schemas/widget-schema.json b/docs/schemas/widget-schema.json new file mode 100644 index 0000000..fdc6e62 --- /dev/null +++ b/docs/schemas/widget-schema.json @@ -0,0 +1,431 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://glance.local/schemas/widget-schema.json", + "title": "GlanceWidgetDefinition", + "description": "JSON Schema for Glance widget definitions. Use this schema with AI structured output modes (Anthropic tool_use, OpenAI response_format) to ensure all required fields are generated correctly.", + "type": "object", + "required": [ + "name", + "slug", + "source_code", + "default_size", + "min_size", + "fetch", + "data_schema", + "credentials" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Human-readable widget name (e.g., 'GitHub Pull Requests')" + }, + "slug": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$", + "minLength": 1, + "maxLength": 50, + "description": "URL-safe identifier (lowercase, hyphens allowed, e.g., 'github-prs')" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Brief description of what the widget displays" + }, + "source_code": { + "type": "string", + "minLength": 10, + "description": "JSX source code for the widget. Must contain a Widget function that receives { serverData } prop." + }, + "server_code": { + "type": "string", + "description": "Server-side code for fetch.type='server_code' widgets. Runs in sandboxed VM." + }, + "server_code_enabled": { + "type": "boolean", + "default": false, + "description": "Whether server_code execution is enabled" + }, + "default_size": { + "type": "object", + "required": ["w", "h"], + "additionalProperties": false, + "properties": { + "w": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "description": "Default width in grid units (1-12)" + }, + "h": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "description": "Default height in grid units (1-20)" + } + }, + "description": "Default widget size on the dashboard grid" + }, + "min_size": { + "type": "object", + "required": ["w", "h"], + "additionalProperties": false, + "properties": { + "w": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "description": "Minimum width in grid units (1-12)" + }, + "h": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "description": "Minimum height in grid units (1-20)" + } + }, + "description": "Minimum widget size (cannot be resized smaller)" + }, + "refresh_interval": { + "type": "integer", + "minimum": 0, + "default": 300, + "description": "Auto-refresh interval in seconds (0 = no auto-refresh)" + }, + "fetch": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["server_code", "webhook", "agent_refresh"], + "description": "How widget data is fetched: server_code (widget calls API), webhook (external service pushes), agent_refresh (AI agent collects)" + }, + "instructions": { + "type": "string", + "minLength": 10, + "description": "REQUIRED for agent_refresh: Detailed markdown instructions for the AI agent on how to collect data" + }, + "schedule": { + "type": "string", + "pattern": "^[*0-9/,-]+ [*0-9/,-]+ [*0-9/,-]+ [*0-9/,-]+ [*0-9/,-]+$", + "description": "REQUIRED for agent_refresh: Cron expression (e.g., '*/15 * * * *' for every 15 minutes)" + }, + "info": { + "type": "string", + "description": "Additional context for AI agents about the fetch process" + }, + "webhook_path": { + "type": "string", + "description": "For webhook type: custom webhook path" + }, + "webhook_setup_instructions": { + "type": "string", + "description": "For webhook type: how to configure the external service" + }, + "refresh_endpoint": { + "type": "string", + "description": "External endpoint to trigger refresh" + }, + "expected_freshness_seconds": { + "type": "integer", + "minimum": 1, + "description": "Agent should refresh within this window" + }, + "max_staleness_seconds": { + "type": "integer", + "minimum": 1, + "description": "Widget shows warning after this duration" + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "agent_refresh" } + }, + "required": ["type"] + }, + "then": { + "required": ["instructions", "schedule"], + "properties": { + "instructions": { + "minLength": 10, + "description": "REQUIRED: Detailed markdown instructions for the AI agent" + }, + "schedule": { + "description": "REQUIRED: Cron expression for refresh schedule" + } + } + } + } + ], + "description": "Data fetching configuration" + }, + "data_schema": { + "type": "object", + "required": ["type", "properties", "required"], + "properties": { + "type": { + "type": "string", + "const": "object", + "description": "Must be 'object'" + }, + "properties": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type (string, number, boolean, array, object)" + }, + "description": { + "type": "string", + "description": "Human-readable description of this field" + }, + "format": { + "type": "string", + "description": "Format hint (e.g., 'date-time', 'uri')" + }, + "items": { + "type": "object", + "description": "For array types: schema for array items" + } + }, + "required": ["type"] + }, + "description": "Object defining each data field and its type" + }, + "required": { + "type": "array", + "items": { + "type": "string" + }, + "contains": { + "const": "fetchedAt" + }, + "minItems": 1, + "description": "Array of required field names. MUST include 'fetchedAt'." + }, + "additionalProperties": { + "type": "boolean", + "default": true + } + }, + "description": "JSON Schema defining the expected data structure. Cache POSTs are validated against this." + }, + "credentials": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "type", "name", "description"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Unique identifier for this credential (e.g., 'github', 'openweather')" + }, + "type": { + "type": "string", + "enum": ["api_key", "local_software", "oauth", "agent"], + "description": "Credential type: api_key (stored in Glance), local_software (must be installed), oauth (OAuth flow), agent (on agent's machine)" + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable name (e.g., 'GitHub Personal Access Token')" + }, + "description": { + "type": "string", + "minLength": 1, + "description": "What this credential is used for" + }, + "info": { + "type": "string", + "description": "Additional context for AI agents" + }, + "obtain_url": { + "type": "string", + "format": "uri", + "description": "For api_key: URL where user can create the credential" + }, + "obtain_instructions": { + "type": "string", + "description": "For api_key: step-by-step instructions to create" + }, + "required_scopes": { + "type": "array", + "items": { "type": "string" }, + "description": "For api_key: required OAuth scopes or permissions" + }, + "check_command": { + "type": "string", + "description": "For local_software: command to check if installed" + }, + "install_url": { + "type": "string", + "format": "uri", + "description": "For local_software: installation URL" + }, + "install_instructions": { + "type": "string", + "description": "For local_software: installation steps" + }, + "agent_tool": { + "type": "string", + "description": "For agent: CLI tool name (e.g., 'gh', 'gcloud')" + }, + "agent_auth_check": { + "type": "string", + "description": "For agent: command to verify auth (e.g., 'gh auth status')" + }, + "agent_auth_instructions": { + "type": "string", + "description": "For agent: how to authenticate the tool" + } + } + }, + "description": "Required credentials/dependencies. Use empty array [] if no credentials needed." + }, + "cache": { + "type": "object", + "properties": { + "ttl_seconds": { + "type": "integer", + "minimum": 1, + "description": "How long data is considered 'fresh'" + }, + "max_staleness_seconds": { + "type": "integer", + "minimum": 1, + "description": "How long data is usable but stale" + }, + "storage": { + "type": "string", + "enum": ["memory", "sqlite"], + "default": "sqlite", + "description": "Cache storage backend" + }, + "on_error": { + "type": "string", + "enum": ["use_stale", "show_error"], + "default": "use_stale", + "description": "Behavior when fetch fails" + }, + "info": { + "type": "string", + "description": "AI agent context for caching strategy" + } + }, + "description": "Cache configuration" + }, + "setup": { + "type": "object", + "required": ["description", "agent_skill", "verification", "idempotent"], + "properties": { + "description": { + "type": "string", + "description": "What needs to be set up" + }, + "agent_skill": { + "type": "string", + "description": "How the agent should perform setup" + }, + "verification": { + "type": "object", + "required": ["type", "target"], + "properties": { + "type": { + "type": "string", + "enum": ["command_succeeds", "endpoint_responds", "cache_populated"], + "description": "How to verify setup succeeded" + }, + "target": { + "type": "string", + "description": "Command, URL, or widget slug to verify" + } + } + }, + "idempotent": { + "type": "boolean", + "description": "Whether setup can be run multiple times safely" + }, + "estimated_time": { + "type": "string", + "description": "Human-readable time estimate" + }, + "info": { + "type": "string", + "description": "AI agent context for setup" + } + }, + "description": "Setup wizard configuration" + }, + "author": { + "type": "string", + "description": "Widget author name or identifier" + } + }, + "examples": [ + { + "name": "GitHub Pull Requests", + "slug": "github-prs", + "description": "Shows open pull requests from a repository", + "source_code": "function Widget({ serverData }) {\n const data = serverData;\n const loading = !serverData;\n const error = serverData?.error;\n \n if (loading) return ;\n if (error) return ;\n \n return (\n
\n ({\n title: pr.title,\n subtitle: `#${pr.number} by ${pr.author}`,\n badge: pr.state\n })) || []} />\n
\n );\n}", + "default_size": { "w": 2, "h": 3 }, + "min_size": { "w": 1, "h": 2 }, + "refresh_interval": 300, + "fetch": { + "type": "agent_refresh", + "schedule": "*/15 * * * *", + "instructions": "## Data Collection\nRun: gh pr list --repo owner/repo --json number,title,author,state\n\n## Data Transformation\nFormat as: { prs: [...], fetchedAt: ISO timestamp }\n\n## Cache Update\nPOST to /api/widgets/github-prs/cache", + "expected_freshness_seconds": 900, + "max_staleness_seconds": 3600 + }, + "data_schema": { + "type": "object", + "properties": { + "prs": { + "type": "array", + "description": "List of pull request objects", + "items": { + "type": "object", + "properties": { + "number": { "type": "integer" }, + "title": { "type": "string" }, + "author": { "type": "string" }, + "state": { "type": "string" } + } + } + }, + "fetchedAt": { + "type": "string", + "format": "date-time", + "description": "ISO timestamp when data was fetched" + } + }, + "required": ["prs", "fetchedAt"] + }, + "credentials": [ + { + "id": "github_cli", + "type": "agent", + "name": "GitHub CLI", + "description": "Agent needs gh CLI authenticated to GitHub", + "agent_tool": "gh", + "agent_auth_check": "gh auth status", + "agent_auth_instructions": "Run `gh auth login` on the machine running the agent" + } + ], + "cache": { + "ttl_seconds": 300, + "max_staleness_seconds": 900, + "storage": "sqlite", + "on_error": "use_stale" + } + } + ] +} diff --git a/src/app/api/dashboard/export/route.ts b/src/app/api/dashboard/export/route.ts index 9984623..29f212b 100644 --- a/src/app/api/dashboard/export/route.ts +++ b/src/app/api/dashboard/export/route.ts @@ -86,10 +86,10 @@ export async function POST(request: NextRequest) { min_size: widget.min_size, refresh_interval: widget.refresh_interval, fetch: widget.fetch, - credentials: widget.credentials.length > 0 ? widget.credentials : undefined, + credentials: widget.credentials, // Always required (can be empty array) setup: widget.setup || undefined, cache: widget.cache || undefined, - data_schema: widget.data_schema || undefined, + data_schema: widget.data_schema, // Always required })); // Build layout diff --git a/src/app/api/dashboard/import/route.ts b/src/app/api/dashboard/import/route.ts index 1c57130..0a0e7fb 100644 --- a/src/app/api/dashboard/import/route.ts +++ b/src/app/api/dashboard/import/route.ts @@ -171,7 +171,7 @@ export async function POST(request: NextRequest) { widget.fetch || { type: "agent_refresh" }, widget.cache || null, null, // author - widget.data_schema || null + widget.data_schema ); existingSlugs.add(newSlug); @@ -205,7 +205,7 @@ export async function POST(request: NextRequest) { widget.fetch || { type: "agent_refresh" }, widget.cache || null, null, // author - widget.data_schema || null + widget.data_schema ); slugMap.set(widget.slug, widget.slug); diff --git a/src/lib/dashboard-format.ts b/src/lib/dashboard-format.ts index 5137fc4..e12af29 100644 --- a/src/lib/dashboard-format.ts +++ b/src/lib/dashboard-format.ts @@ -32,10 +32,10 @@ export interface DashboardExportFormat { min_size: { w: number; h: number }; refresh_interval: number; fetch: FetchConfig; - credentials?: CredentialRequirement[]; + credentials: CredentialRequirement[]; setup?: SetupConfig; cache?: CacheConfig; - data_schema?: DataSchema; + data_schema: DataSchema; }>; layout: { desktop: Array<{ @@ -161,14 +161,146 @@ export interface ImportResponse { /** * Validates the structure of a dashboard export file */ +/** + * Validates a size object has positive w and h integers + */ +function validateSize( + size: unknown, + fieldName: string, + widgetId: string +): string[] { + const errors: string[] = []; + if (!size || typeof size !== "object") { + errors.push(`Widget ${widgetId}: missing or invalid ${fieldName}`); + return errors; + } + const s = size as Record; + if (typeof s.w !== "number" || !Number.isInteger(s.w) || s.w < 1) { + errors.push(`Widget ${widgetId}: ${fieldName}.w must be a positive integer`); + } + if (typeof s.h !== "number" || !Number.isInteger(s.h) || s.h < 1) { + errors.push(`Widget ${widgetId}: ${fieldName}.h must be a positive integer`); + } + return errors; +} + +/** + * Validates fetch configuration + */ +function validateFetch( + fetch: unknown, + widgetId: string +): string[] { + const errors: string[] = []; + if (!fetch || typeof fetch !== "object") { + errors.push(`Widget ${widgetId}: missing or invalid fetch configuration`); + return errors; + } + const f = fetch as Record; + + const validTypes = ["server_code", "webhook", "agent_refresh"]; + if (!f.type || !validTypes.includes(f.type as string)) { + errors.push( + `Widget ${widgetId}: fetch.type must be one of: ${validTypes.join(", ")}` + ); + return errors; + } + + // agent_refresh requires instructions and schedule + if (f.type === "agent_refresh") { + if (!f.instructions || typeof f.instructions !== "string" || f.instructions.length < 10) { + errors.push( + `Widget ${widgetId}: fetch.instructions is required for agent_refresh widgets (min 10 chars)` + ); + } + if (!f.schedule || typeof f.schedule !== "string") { + errors.push( + `Widget ${widgetId}: fetch.schedule (cron expression) is required for agent_refresh widgets` + ); + } + } + + return errors; +} + +/** + * Validates data_schema configuration + */ +function validateDataSchema( + schema: unknown, + widgetId: string +): string[] { + const errors: string[] = []; + if (!schema || typeof schema !== "object") { + errors.push(`Widget ${widgetId}: missing or invalid data_schema`); + return errors; + } + const s = schema as Record; + + if (s.type !== "object") { + errors.push(`Widget ${widgetId}: data_schema.type must be "object"`); + } + + if (!s.properties || typeof s.properties !== "object") { + errors.push(`Widget ${widgetId}: data_schema.properties is required`); + } + + if (!Array.isArray(s.required)) { + errors.push(`Widget ${widgetId}: data_schema.required must be an array`); + } else if (!s.required.includes("fetchedAt")) { + errors.push( + `Widget ${widgetId}: data_schema.required must include "fetchedAt"` + ); + } + + return errors; +} + +/** + * Validates credentials array + */ +function validateCredentials( + credentials: unknown, + widgetId: string +): string[] { + const errors: string[] = []; + if (!Array.isArray(credentials)) { + errors.push(`Widget ${widgetId}: credentials must be an array (use [] if none needed)`); + return errors; + } + + const validTypes = ["api_key", "local_software", "oauth", "agent"]; + for (let i = 0; i < credentials.length; i++) { + const cred = credentials[i] as Record; + if (!cred.id || typeof cred.id !== "string") { + errors.push(`Widget ${widgetId}: credentials[${i}].id is required`); + } + if (!cred.type || !validTypes.includes(cred.type as string)) { + errors.push( + `Widget ${widgetId}: credentials[${i}].type must be one of: ${validTypes.join(", ")}` + ); + } + if (!cred.name || typeof cred.name !== "string") { + errors.push(`Widget ${widgetId}: credentials[${i}].name is required`); + } + if (!cred.description || typeof cred.description !== "string") { + errors.push(`Widget ${widgetId}: credentials[${i}].description is required`); + } + } + + return errors; +} + export function validateDashboardFormat(data: unknown): { valid: boolean; errors: string[]; + warnings: string[]; } { const errors: string[] = []; + const warnings: string[] = []; if (!data || typeof data !== "object") { - return { valid: false, errors: ["Invalid JSON structure"] }; + return { valid: false, errors: ["Invalid JSON structure"], warnings: [] }; } const dashboard = data as Record; @@ -189,17 +321,37 @@ export function validateDashboardFormat(data: unknown): { const widgets = dashboard.widgets as Array>; for (let i = 0; i < widgets.length; i++) { const widget = widgets[i]; + const widgetId = (widget.slug as string) || `#${i + 1}`; + + // Basic required fields if (!widget.slug || typeof widget.slug !== "string") { errors.push(`Widget ${i + 1}: missing or invalid slug`); + } else if (!/^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/.test(widget.slug as string)) { + errors.push(`Widget ${widgetId}: slug must be lowercase with hyphens only`); } + if (!widget.name || typeof widget.name !== "string") { - errors.push(`Widget ${i + 1}: missing or invalid name`); + errors.push(`Widget ${widgetId}: missing or invalid name`); } + if (!widget.source_code || typeof widget.source_code !== "string") { - errors.push( - `Widget ${widget.slug || i + 1}: missing or invalid source_code` - ); + errors.push(`Widget ${widgetId}: missing or invalid source_code`); + } else if ((widget.source_code as string).length < 10) { + errors.push(`Widget ${widgetId}: source_code is too short`); } + + // Size validation + errors.push(...validateSize(widget.default_size, "default_size", widgetId)); + errors.push(...validateSize(widget.min_size, "min_size", widgetId)); + + // Fetch configuration validation + errors.push(...validateFetch(widget.fetch, widgetId)); + + // Data schema validation (REQUIRED) + errors.push(...validateDataSchema(widget.data_schema, widgetId)); + + // Credentials validation (REQUIRED, can be empty array) + errors.push(...validateCredentials(widget.credentials, widgetId)); } } @@ -212,5 +364,5 @@ export function validateDashboardFormat(data: unknown): { } } - return { valid: errors.length === 0, errors }; + return { valid: errors.length === 0, errors, warnings }; } diff --git a/src/lib/db.ts b/src/lib/db.ts index a24542b..f061936 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -660,8 +660,8 @@ export interface CustomWidget { cache: CacheConfig | null; author: string | null; error?: ErrorConfig; - // Data validation schema (JSON Schema format) - data_schema: DataSchema | null; + // Data validation schema (JSON Schema format) - REQUIRED for all widgets + data_schema: DataSchema; } export interface WidgetSetup { @@ -697,7 +697,11 @@ function rowToCustomWidget(row: CustomWidgetRow): CustomWidget { fetch: row.fetch ? JSON.parse(row.fetch) : { type: "server_code" }, cache: row.cache ? JSON.parse(row.cache) : null, author: row.author, - data_schema: row.data_schema ? JSON.parse(row.data_schema) : null, + data_schema: row.data_schema ? JSON.parse(row.data_schema) : { + type: "object", + properties: { fetchedAt: { type: "string", format: "date-time" } }, + required: ["fetchedAt"], + }, }; } diff --git a/src/lib/widget-package.ts b/src/lib/widget-package.ts index cecbcff..dd76237 100644 --- a/src/lib/widget-package.ts +++ b/src/lib/widget-package.ts @@ -57,8 +57,8 @@ export interface WidgetPackage { fetch: FetchConfig; cache?: CacheConfig; - // Data validation schema (JSON Schema format) - data_schema?: DataSchema; + // Data validation schema (JSON Schema format) - REQUIRED + data_schema: DataSchema; } /** @@ -100,7 +100,7 @@ export function encodeWidgetPackage( setup: widget.setup || undefined, fetch: widget.fetch, cache: widget.cache || undefined, - data_schema: widget.data_schema || undefined, + data_schema: widget.data_schema, }; const json = JSON.stringify(pkg); @@ -260,6 +260,23 @@ export function validateWidgetPackage(pkg: WidgetPackage): ValidationResult { warnings.push("server_code_enabled is true but no server_code provided"); } + // Validate data_schema (REQUIRED) + if (!pkg.data_schema) { + errors.push("Missing data_schema (required for cache validation)"); + } else { + if (pkg.data_schema.type !== "object") { + errors.push("data_schema.type must be 'object'"); + } + if (!pkg.data_schema.properties || typeof pkg.data_schema.properties !== "object") { + errors.push("data_schema.properties is required"); + } + if (!Array.isArray(pkg.data_schema.required)) { + errors.push("data_schema.required must be an array"); + } else if (!pkg.data_schema.required.includes("fetchedAt")) { + errors.push("data_schema.required must include 'fetchedAt'"); + } + } + // Validate cache config if provided if (pkg.cache) { if (typeof pkg.cache.ttl_seconds !== "number" || pkg.cache.ttl_seconds < 0) { @@ -317,7 +334,7 @@ export function packageToWidget(pkg: WidgetPackage): Omit< fetch: pkg.fetch, cache: pkg.cache || null, author: pkg.meta.author || null, - data_schema: pkg.data_schema || null, + data_schema: pkg.data_schema, }; }