diff --git a/docs/integrations/TICKTAR.md b/docs/integrations/TICKTAR.md index 0123c69..4517205 100644 --- a/docs/integrations/TICKTAR.md +++ b/docs/integrations/TICKTAR.md @@ -17,8 +17,11 @@ Authorization: Bearer FRAME_FLOW_TOKEN. The bootstrap token is provided through client_id, name, permissions, enabled, created_at, last_used_at. Permissions: devices:read, images:read, images:delete, display:write, templates:read, render:write, jobs:read, jobs:write, history:read, settings:write, admin. -0.1.0 seeds one admin token; provisioning scoped client rows is supported by middleware -but multi-token management UI is not implemented. Use a trusted server-to-server +The deployment token seeds an administrator. Administrators can create separate +scoped clients through Connections or POST /api/v1/clients. The returned token is +shown once; GET /api/v1/clients never returns tokens. DELETE /api/v1/clients/{id} +revokes access. For Ticktar, grant devices:read, templates:read, render:write and +display:write; add images:read or jobs permissions only when needed. Use a trusted server-to-server connection; CORS is opt-in by exact origins, never wildcard by default. ## Discovery and capabilities @@ -86,13 +89,19 @@ The authenticated client is authoritative; source.client is optional metadata. History stores client_id/request_id/operation_id/device_id/action/result/timestamp. No internal endpoints, headers, stacks, paths or secrets are returned. -## Future webhook +## Webhooks -SQLite events and webhook_subscriptions reserve operation.completed/failed, -display.changed, device.online/offline/low_battery events. 0.1.0 emits operation/display -events only; no callback delivery, retry worker or registration API is implemented. -Future deliveries should use signed payloads and bounded retries. -OAuth, tenancy, RBAC management and generated SDKs are future work. +Administrators register callbacks through POST /api/v1/webhooks. Allowed public +HTTPS hosts must be configured explicitly. Durable callbacks cover +operation.completed, operation.failed, display.changed and observed +device.online/device.offline/device.low_battery transitions. Device observation +runs at most once per minute; failed cloud queries do not imply offline. + +X-Frame-Flow-Signature: sha256= is HMAC-SHA256 over the exact request body. +Signing secrets are shown once and stored in mode-0600 files. Verify signatures +and deduplicate event IDs. Delivery retries at most three times; events before +registration are not replayed. Polling Operations remains available. +OAuth, tenancy and generated SDKs are future work. ## Compatibility diff --git a/pigway_frame_flow/extensions/ai_images.py b/pigway_frame_flow/extensions/ai_images.py index 46039fe..4864689 100644 --- a/pigway_frame_flow/extensions/ai_images.py +++ b/pigway_frame_flow/extensions/ai_images.py @@ -146,6 +146,7 @@ def install_ai_images(app, service): } ).encode(), limit=service.settings.max_bytes, + timeout=180, ) result = json.loads(raw)["data"] if len(result) != 1: diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index 82e3175..b76cea2 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -161,6 +161,7 @@ class Assets: body=None, limit=None, accepted_status=(200,), + timeout=10, ): limit = limit or self.settings.max_bytes parsed = urlsplit(url) @@ -193,12 +194,12 @@ class Assets: class PinnedHTTPS(http.client.HTTPSConnection): def connect(self): - sock = socket.create_connection((ip, 443), timeout=10) + sock = socket.create_connection((ip, 443), timeout=timeout) self.sock = ssl.create_default_context().wrap_socket( sock, server_hostname=parsed.hostname ) - connection = PinnedHTTPS(parsed.hostname, timeout=10) + connection = PinnedHTTPS(parsed.hostname, timeout=timeout) try: connection.request( method, diff --git a/scripts/ui-smoke.cjs b/scripts/ui-smoke.cjs index 54531e9..2000338 100644 --- a/scripts/ui-smoke.cjs +++ b/scripts/ui-smoke.cjs @@ -17,7 +17,7 @@ const fs=require('fs');const path=require('path');const assert=require('assert') await page.locator('#make-preview').click();await page.waitForFunction(()=>!document.querySelector('#save-local').disabled);checks++; await page.locator('#save-local').click();const uploaded=page.locator('.image-card').filter({hasText:uploadName+'.jpg'});await uploaded.waitFor();checks++; await uploaded.locator('[data-delete]').click();await page.locator('#modal-confirm').click();await uploaded.waitFor({state:'hidden'});checks++; - await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===7);checks++; + await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===9);checks++; await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++; await page.locator('#field-title').fill('Edited title');assert(await page.locator('#display-template').isDisabled());checks++;await page.locator('#save-template').click();await page.reload();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Edited title');checks++;await page.locator('#reset-template').click();await page.locator('#modal-confirm').click();await page.waitForFunction(()=>document.querySelector('#field-title')?.value!=='Edited title'&&document.querySelector('#field-title'));assert(await page.locator('#field-title').inputValue()!=='Edited title');checks++;await page.locator('#field-title').fill('Provenance UI title');await page.locator('#render-template').click();await page.locator('#template-image').waitFor();await page.locator('#display-template').click();await page.locator('#confirm-display').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;await page.locator('tbody tr button').first().click();await page.locator('#modal-cancel').click();checks++;await page.locator('[data-page="dashboard"]').click();await page.locator('#edit-current-template').waitFor({timeout:30000});await page.locator('#edit-current-template').click();await page.locator('#field-title').waitFor();assert(await page.locator('#field-title').inputValue()==='Provenance UI title');checks++; await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-content-type').waitFor();await page.locator('#job-template').selectOption('quote');checks++;await page.locator('#job-name').fill('UI acceptance');await page.locator('#job-form button[type=submit]').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor();checks++; diff --git a/tests/test_ai_images.py b/tests/test_ai_images.py index 3395d0b..8f9e860 100644 --- a/tests/test_ai_images.py +++ b/tests/test_ai_images.py @@ -50,6 +50,7 @@ def test_generation_queued_once_and_never_displays(client, service, monkeypatch) operation = service.operation(first.json()["operation_id"]) assert operation["status"] == "COMPLETED" assert len(calls) == 1 + assert calls[0]["timeout"] == 180 assert json.loads(calls[0]["body"])["n"] == 1 assert service.registry.get("living-room").client.uploads == 0 assert "private-ai-key" not in client.get("/api/v1/settings/ai-images").text