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,
};
}