Align integration contract and image generation validation

This commit is contained in:
Pigway
2026-10-10 01:25:32 +08:00
parent b9b33b83bb
commit 4acd616555
5 changed files with 23 additions and 11 deletions
+17 -8
View File
@@ -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: client_id, name, permissions, enabled, created_at, last_used_at. Permissions:
devices:read, images:read, images:delete, display:write, templates:read, render:write, devices:read, images:read, images:delete, display:write, templates:read, render:write,
jobs:read, jobs:write, history:read, settings:write, admin. jobs:read, jobs:write, history:read, settings:write, admin.
0.1.0 seeds one admin token; provisioning scoped client rows is supported by middleware The deployment token seeds an administrator. Administrators can create separate
but multi-token management UI is not implemented. Use a trusted server-to-server 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. connection; CORS is opt-in by exact origins, never wildcard by default.
## Discovery and capabilities ## 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. History stores client_id/request_id/operation_id/device_id/action/result/timestamp.
No internal endpoints, headers, stacks, paths or secrets are returned. No internal endpoints, headers, stacks, paths or secrets are returned.
## Future webhook ## Webhooks
SQLite events and webhook_subscriptions reserve operation.completed/failed, Administrators register callbacks through POST /api/v1/webhooks. Allowed public
display.changed, device.online/offline/low_battery events. 0.1.0 emits operation/display HTTPS hosts must be configured explicitly. Durable callbacks cover
events only; no callback delivery, retry worker or registration API is implemented. operation.completed, operation.failed, display.changed and observed
Future deliveries should use signed payloads and bounded retries. device.online/device.offline/device.low_battery transitions. Device observation
OAuth, tenancy, RBAC management and generated SDKs are future work. runs at most once per minute; failed cloud queries do not imply offline.
X-Frame-Flow-Signature: sha256=<hex> 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 ## Compatibility
@@ -146,6 +146,7 @@ def install_ai_images(app, service):
} }
).encode(), ).encode(),
limit=service.settings.max_bytes, limit=service.settings.max_bytes,
timeout=180,
) )
result = json.loads(raw)["data"] result = json.loads(raw)["data"]
if len(result) != 1: if len(result) != 1:
+3 -2
View File
@@ -161,6 +161,7 @@ class Assets:
body=None, body=None,
limit=None, limit=None,
accepted_status=(200,), accepted_status=(200,),
timeout=10,
): ):
limit = limit or self.settings.max_bytes limit = limit or self.settings.max_bytes
parsed = urlsplit(url) parsed = urlsplit(url)
@@ -193,12 +194,12 @@ class Assets:
class PinnedHTTPS(http.client.HTTPSConnection): class PinnedHTTPS(http.client.HTTPSConnection):
def connect(self): 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( self.sock = ssl.create_default_context().wrap_socket(
sock, server_hostname=parsed.hostname sock, server_hostname=parsed.hostname
) )
connection = PinnedHTTPS(parsed.hostname, timeout=10) connection = PinnedHTTPS(parsed.hostname, timeout=timeout)
try: try:
connection.request( connection.request(
method, method,
+1 -1
View File
@@ -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('#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 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 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('#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('#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++; 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++;
+1
View File
@@ -50,6 +50,7 @@ def test_generation_queued_once_and_never_displays(client, service, monkeypatch)
operation = service.operation(first.json()["operation_id"]) operation = service.operation(first.json()["operation_id"])
assert operation["status"] == "COMPLETED" assert operation["status"] == "COMPLETED"
assert len(calls) == 1 assert len(calls) == 1
assert calls[0]["timeout"] == 180
assert json.loads(calls[0]["body"])["n"] == 1 assert json.loads(calls[0]["body"])["n"] == 1
assert service.registry.get("living-room").client.uploads == 0 assert service.registry.get("living-room").client.uploads == 0
assert "private-ai-key" not in client.get("/api/v1/settings/ai-images").text assert "private-ai-key" not in client.get("/api/v1/settings/ai-images").text