Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09c78d3ab8 | |||
| b2ae48e250 | |||
| 8abc79dc5b | |||
| 67765c17f4 | |||
| 630ed42d53 | |||
| 07fd678cf3 | |||
| 7e1bd538c1 | |||
| 56cd0c5715 | |||
| b4e537b4ad |
@@ -212,6 +212,7 @@ Collapsing/scaling presentation does not rewrite child content. Projection is
|
||||
|
||||
| Route | Required input / behavior |
|
||||
| --- | --- |
|
||||
| POST B/connections | `{source,target,edge,expected_versions}` + Idempotency-Key; atomically creates/reuses boundary port chains and stores one edge in the lowest common ancestor |
|
||||
| POST B/subgraphs | Parent If-Match; `{frame:{id,title,position,width,height,labelMode}}`; returns 201, child_board, parent/child snapshots and receipt |
|
||||
| POST B/subgraphs/{frame}/members | `{source_board_id,target_board_id,node_ids,expected_versions}`; parent/child or siblings coordinated by their common parent |
|
||||
| POST B/subgraphs/{frame}/dissolve | `{expected_versions}`; preserves content in the parent and archives the emptied child |
|
||||
@@ -350,19 +351,163 @@ On 2026-11-06, the isolated real check succeeded with text model
|
||||
`gpt-6.1-sol`. The image decoded as PNG, 1254×1254, 857,723 bytes. These are
|
||||
verification observations, not a hardcoded catalog or a production deployment
|
||||
claim; temporary run/asset records were cleaned up. Always query the live catalog.
|
||||
The isolated text run was `a38ea762-d3bf-41c2-a43a-679e5785f3d7`; the image run
|
||||
was `6c0beaa0-d182-45b5-9075-110fcc800218` and its authenticated reread asset was
|
||||
`bf2733bc-8aca-4bca-97dc-3cfa52d48270`, SHA-256
|
||||
`2ffda6b1603fd0bab27f63479d7f2ad316b65f663afc57ba3f8fefa92a8edce4`.
|
||||
These cleaned-up isolated IDs are audit observations, not live production resources.
|
||||
|
||||
For production, the worker can load operator-controlled `codex/canvas.env` with
|
||||
`FOUNDRY_CANVAS_IMAGE_MODELS` containing verified backend names. Replace the worker
|
||||
with the reviewed Linux build through the normal deployment process, apply the
|
||||
numbered migrations and confirm capabilities before migrating a real canvas.
|
||||
|
||||
The user-selected migration target is the board addressed by workspace slug
|
||||
`workspace-8d775c90-74c9-4109-a1ad-6d0ad3a00ac7` and board slug
|
||||
`canvas-a9f9a985-e99a-43eb-b088-7edc9301452f`. Production migration remains pending
|
||||
manual PR merge/deployment. Resolve canonical IDs, take a read-only preflight,
|
||||
execute only its current plan, and record before/after versions, frame/child IDs,
|
||||
counts and authenticated V2 rereads. Do not migrate other account resources or let
|
||||
a legacy client overwrite the migrated board.
|
||||
Production deployment of merge commit `b4e537b4adedeb524971cbbdd3f52c61257b10da`
|
||||
succeeded on the confirmed ECS runner on 2026-11-06. Main checks
|
||||
[run 912](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/912)
|
||||
and deployment
|
||||
[run 913](https://gitea.entroia.com/Zayden-Jung/foundry-core/actions/runs/913)
|
||||
both succeeded. The base URL is `https://api.foundry.entroia.com`.
|
||||
The deployed capabilities endpoint reported contract `canvas-v2-2026-11-06`
|
||||
with both text and image available; the worker uses the backend verified above.
|
||||
|
||||
Only the user-selected existing test canvas was migrated through the production
|
||||
HTTPS API, using a temporary operator session with an absolute three-minute
|
||||
expiry and the existing account's editor/admin permissions. Each preflight or
|
||||
verification session was revoked immediately afterwards; no credential was
|
||||
printed or persisted. The dry-run reported no issues, one legacy frame and two
|
||||
explicit members. The version-bound migration committed atomically:
|
||||
|
||||
| Item | Verified value |
|
||||
| --- | --- |
|
||||
| Project | `endoleap` |
|
||||
| Canonical workspace | `8d775c90-74c9-4109-a1ad-6d0ad3a00ac7` |
|
||||
| Canonical parent board | `a9f9a985-e99a-43eb-b088-7edc9301452f` |
|
||||
| Parent version/schema | 209 / V1 → 210 / V2 |
|
||||
| Frame | `f2a00495-3740-43a6-aa52-611e4dd8520f` |
|
||||
| Canonical child board | `9c9bd83b-2444-41f8-8e0e-bfd6d8873577` |
|
||||
| Child version | 1 / V2 |
|
||||
| Transaction | `4a6e6fee-683e-4025-8ef8-47092a80331b` |
|
||||
| Undo expiry | `2026-11-13T10:56:40.241092Z` |
|
||||
| Nodes | 11 preserved: parent 9, child 2 |
|
||||
| Edges | 2 preserved in parent: 1 mapped boundary edge, 1 unchanged parent edge |
|
||||
|
||||
Authenticated V2 rereads matched the committed snapshots. The two explicit
|
||||
members retained their content, image references, dimensions and styles, with
|
||||
positions converted to child coordinates. The boundary edge retained its ID,
|
||||
style and path, and references a stable port targeting the original node/handle.
|
||||
Retrying the same operation returned the same transaction; a fresh dry-run
|
||||
reported zero additional children. A legacy GET returned
|
||||
409 `canvas_schema_upgrade_required`. No other canvas was migrated.
|
||||
|
||||
The source snapshot, plan, complete transaction response, reread snapshots and
|
||||
summary are retained with mode 0600 under
|
||||
`/opt/endoleap-infra/foundry/canvas-v2-migration-a9f9a985-e99a-43eb-b088-7edc9301452f`
|
||||
on the ECS host, in addition to the database receipt. The frontend V2 integration
|
||||
remains a separate task; use a V2-aware client for this migrated test canvas.
|
||||
|
||||
#### Creating new cross-boundary connections
|
||||
|
||||
Ordinary Canvas PATCH preserves existing frame ports and cannot add or retarget
|
||||
ports. Migration/movement-generated ports are reusable, but creating a new
|
||||
external connection to an unexposed child node requires this dedicated command:
|
||||
|
||||
`POST B/connections` with user session authorization,
|
||||
`Accept: application/vnd.foundry.canvas+json;version=2`, JSON content type and a
|
||||
UUID `Idempotency-Key`. Availability is advertised as
|
||||
`capabilities.subgraphs.connections: true`; clients must check the deployed
|
||||
capability before calling it.
|
||||
|
||||
`B` identifies the **lowest common ancestor board** of the two actual endpoints.
|
||||
The coordinator may itself be a nested board. Both endpoints must be in this
|
||||
same project/workspace and inside its active subgraph tree. A direct endpoint on
|
||||
B is valid, as are siblings and descendants up to the existing depth limit.
|
||||
When both endpoints are in the same board, use that board as B.
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {"board_id":"<child-board-uuid>","node_id":"child-text","handle":"right"},
|
||||
"target": {"board_id":"<parent-board-uuid>","node_id":"parent-image","handle":"left"},
|
||||
"edge": {
|
||||
"id":"new-edge",
|
||||
"data": {
|
||||
"appearance":{"color":"#AA968B","width":2},
|
||||
"animation":{"mode":"executing","effect":"flow","direction":"forward","durationMs":1600},
|
||||
"execution":{"enabled":true}
|
||||
}
|
||||
},
|
||||
"expected_versions": {"<parent-board-uuid>":210,"<child-board-uuid>":1}
|
||||
}
|
||||
```
|
||||
|
||||
Use canonical board UUIDs for endpoints and version keys. Eligible real node
|
||||
kinds are text/script/plain-text/image/plain-image; handles must be one of
|
||||
`top/right/bottom/left`. Do not submit projected node IDs, frame IDs as generated
|
||||
content, or `port:*` handles. The server exposes a real endpoint through each
|
||||
containing frame, creating or reusing a port with the matching
|
||||
`direction/nodeId/handle` tuple. Existing matching ports retain their IDs,
|
||||
including migration-created or custom stable IDs. A source chain uses output
|
||||
ports, and a target chain uses input ports; these are distinct. No unbound port
|
||||
or writable arbitrary child reference is accepted.
|
||||
|
||||
The expected_versions map must contain **exactly every board on both paths**,
|
||||
including the endpoint boards and B. For a grandchild-to-parent connection it
|
||||
contains parent, intermediate child and grandchild; for siblings it contains
|
||||
both children and their parent. No separate If-Match header is required for this
|
||||
multi-board operation. Missing participants return 428 `version_required`;
|
||||
when a nonempty map has missing/extra participant keys, the response includes
|
||||
`details.required_board_ids`. Any stale version returns 412 `version_conflict`.
|
||||
All participants require current editor/admin access. Calling a higher ancestor
|
||||
instead of the lowest common ancestor returns 409 `connection_owner_mismatch`.
|
||||
An unrelated/invalid ownership path returns 409 `ownership_conflict`; resources
|
||||
outside the caller's scope retain the normal 403/404 behavior.
|
||||
|
||||
Only `edge.id` and optional typed V2 `edge.data` are accepted. Its appearance,
|
||||
animation, execution flag and bezier-chain path follow the normal V2 validation.
|
||||
All knots are already in B's local coordinates; port creation does not move node
|
||||
geometry or re-layout the edge. An existing edge ID in B returns 409
|
||||
`edge_id_conflict`. Graph, port and snapshot capacity failures return 413 without
|
||||
saving any ports, edges, versions or receipt.
|
||||
|
||||
Success is 201 with the coordinator's ETag and:
|
||||
|
||||
```json
|
||||
{
|
||||
"transaction_id":"<uuid>",
|
||||
"parent":{"board_id":"<coordinator>","version":211,"document":{}},
|
||||
"snapshots":[
|
||||
{"board_id":"<coordinator>","version":211,"document":{}},
|
||||
{"board_id":"<child-board-uuid>","version":2,"document":{}}
|
||||
],
|
||||
"connection":{
|
||||
"board_id":"<coordinator>",
|
||||
"edge":{"id":"new-edge","source":"<frame-id>","sourceHandle":"port:<stable-id>","target":"parent-image","targetHandle":"left","data":{}},
|
||||
"source":{"board_id":"<child-board-uuid>","node_id":"child-text","handle":"right"},
|
||||
"target":{"board_id":"<parent-board-uuid>","node_id":"parent-image","handle":"left"}
|
||||
},
|
||||
"created_ports":[{"board_id":"<coordinator>","frame_id":"<frame-id>","port_id":"<stable-id>"}],
|
||||
"undo_receipt":{"transaction_id":"<uuid>","valid_for_seconds":604800,"expires_at":"<RFC3339 timestamp>"}
|
||||
}
|
||||
```
|
||||
|
||||
The JSON above abbreviates document values and shows a one-level example; nested
|
||||
connections include every intermediate board in snapshots. The response edge is
|
||||
the stored owner-board edge, while source/target preserve the logical endpoint
|
||||
identities. created_ports contains only newly allocated ports; reused ports are
|
||||
available in the snapshots. Each participating board advances exactly one
|
||||
version (including endpoint boards whose node content remains unchanged),
|
||||
consistent with other cross-board commands. Flush pending saves before creating
|
||||
the connection, then absorb **all** returned versions/snapshots before resuming
|
||||
autosave. Retrying an identical body with the same key returns the original 201
|
||||
response and does not advance versions again. Changing the body, including its
|
||||
version map, with that key returns 409 `idempotency_conflict`.
|
||||
|
||||
Undo uses the existing `POST B/transactions/{transaction_id}/undo` endpoint with
|
||||
a new key and all current participant versions. It restores the previous edge
|
||||
and port arrays atomically; subsequent incompatible edits cause 412. Existing
|
||||
ports reused by the command remain after undo. Redo submits the command with
|
||||
fresh versions and a new key. Ordinary PATCH can continue to edit/remove edges
|
||||
using already exposed ports, but cannot retarget or directly delete the ports.
|
||||
|
||||
#### Reproducible session API examples
|
||||
|
||||
@@ -459,3 +604,31 @@ api "$B/ai/runs/$(jq -r '.run_id' <<<"$propagated_run")?after_seq=0&limit=100"
|
||||
The live catalog, rather than the isolated verification IDs above, determines
|
||||
whether each request can run. A 202 response acknowledges a queued job; use its
|
||||
terminal status and authenticated canvas/asset rereads as completion evidence.
|
||||
|
||||
|
||||
### Managed image previews
|
||||
|
||||
Original asset bytes remain immutable and are used by the ordinary asset URL,
|
||||
downloads and AI inputs. Authenticated `GET .../assets/{id}?variant=preview`
|
||||
returns one display rendition bounded to 1024 pixels on its longest edge. Opaque previews use JPEG quality 82, transparency stays PNG, GIF preserves
|
||||
the original animation, and JPEG EXIF orientation is applied. Small originals
|
||||
may be returned unchanged when this is more efficient. No preview URL belongs
|
||||
in node `data.src`: persist the original managed URL and optional original
|
||||
`assetId`, then select the display variant only in the renderer.
|
||||
|
||||
Permissions are checked on every read, including cache hits. Originals are not
|
||||
replaced. Generation is serialized and the immutable encoded preview cache is
|
||||
bounded to 32 MiB / 128 entries. Current capabilities advertise `assets.previews`
|
||||
and `assets.preview_max_edge`. No database migration is needed. Asset reads and
|
||||
Canvas GET release their permission transaction before encoding/writing the
|
||||
response. Uploads release the initial workspace lock before receiving a large
|
||||
multipart body and recheck current write permission before committing.
|
||||
|
||||
Legacy inline images must be migrated through an explicit editor action: upload
|
||||
unchanged bytes with idempotency keys, then save the same node IDs/geometry with
|
||||
managed asset references and the current canvas version. Opening a canvas does
|
||||
not migrate it or cause an implicit PATCH.
|
||||
|
||||
#### Atomic member drop positions
|
||||
|
||||
`POST B/subgraphs/{frame}/members` additionally accepts optional `positions`, an object with exactly one `{x,y}` entry for each `node_ids` item, expressed in the **source board's local coordinates**. Both coordinates are required, finite and within ±1,000,000. Capabilities advertise `subgraphs.member_positions: true`. Omission preserves legacy behavior. Positions, ownership, edge/port conversion, versions and undo receipt commit in one transaction; no preceding coordinate PATCH is needed. Validation failures or stale versions perform zero writes. The positions participate in the idempotency payload digest; retries must keep the entire body and key unchanged. Undo restores the pre-drop snapshot, including original positions.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type assetPreviewResult struct {
|
||||
data []byte
|
||||
mime string
|
||||
}
|
||||
type assetPreviewEntry struct {
|
||||
key string
|
||||
value assetPreviewResult
|
||||
}
|
||||
|
||||
var previewMemory = struct {
|
||||
sync.Mutex
|
||||
entries map[string]*list.Element
|
||||
order *list.List
|
||||
bytes int
|
||||
}{entries: make(map[string]*list.Element), order: list.New()}
|
||||
var previewSlots = make(chan struct{}, 1)
|
||||
|
||||
func previewWidth(variant, width string) (int, error) {
|
||||
if variant == "" && width == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if variant == "preview" && (width == "" || width == "1024") {
|
||||
return 1024, nil
|
||||
}
|
||||
return 0, errors.New("invalid_asset_variant")
|
||||
}
|
||||
|
||||
// Cache only immutable bytes, after the caller checks current asset permission.
|
||||
// Bound both decoded concurrency and retained encoded data.
|
||||
func cachedAssetPreview(ctx context.Context, encoded []byte, mime string, width int) (assetPreviewResult, error) {
|
||||
key := fmt.Sprintf("%x:%d", sha256.Sum256(encoded), width)
|
||||
previewMemory.Lock()
|
||||
if entry := previewMemory.entries[key]; entry != nil {
|
||||
previewMemory.order.MoveToFront(entry)
|
||||
result := entry.Value.(assetPreviewEntry).value
|
||||
previewMemory.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
previewMemory.Unlock()
|
||||
select {
|
||||
case previewSlots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return assetPreviewResult{}, ctx.Err()
|
||||
}
|
||||
defer func() { <-previewSlots }()
|
||||
// Another request may have finished this same preview while we waited.
|
||||
previewMemory.Lock()
|
||||
if entry := previewMemory.entries[key]; entry != nil {
|
||||
result := entry.Value.(assetPreviewEntry).value
|
||||
previewMemory.order.MoveToFront(entry)
|
||||
previewMemory.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
previewMemory.Unlock()
|
||||
result, err := makeAssetPreview(encoded, mime, width)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if len(result.data) > 8<<20 {
|
||||
return result, nil
|
||||
}
|
||||
previewMemory.Lock()
|
||||
defer previewMemory.Unlock()
|
||||
for previewMemory.bytes+len(result.data) > 32<<20 || previewMemory.order.Len() >= 128 {
|
||||
tail := previewMemory.order.Back()
|
||||
if tail == nil {
|
||||
break
|
||||
}
|
||||
old := tail.Value.(assetPreviewEntry)
|
||||
previewMemory.bytes -= len(old.value.data)
|
||||
delete(previewMemory.entries, old.key)
|
||||
previewMemory.order.Remove(tail)
|
||||
}
|
||||
previewMemory.entries[key] = previewMemory.order.PushFront(assetPreviewEntry{key, result})
|
||||
previewMemory.bytes += len(result.data)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func makeAssetPreview(encoded []byte, mime string, width int) (assetPreviewResult, error) {
|
||||
original := assetPreviewResult{encoded, mime}
|
||||
// Keep animation intact. Original bytes remain the canonical AI/download input.
|
||||
if mime == "image/gif" {
|
||||
return original, nil
|
||||
}
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return original, err
|
||||
}
|
||||
if config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > assetPixelLimit {
|
||||
return original, errors.New("invalid_image_dimensions")
|
||||
}
|
||||
if config.Width <= width && config.Height <= width && len(encoded) <= 64<<10 {
|
||||
return original, nil
|
||||
}
|
||||
source, _, err := image.Decode(bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return original, err
|
||||
}
|
||||
orientation := jpegOrientation(encoded)
|
||||
if orientation != 1 {
|
||||
source = orientedAsset{source, orientation}
|
||||
}
|
||||
bounds := source.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
if w > width || h > width {
|
||||
if w >= h {
|
||||
h = max(1, h*width/w)
|
||||
w = width
|
||||
} else {
|
||||
w = max(1, w*width/h)
|
||||
h = width
|
||||
}
|
||||
}
|
||||
resized := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
draw.CatmullRom.Scale(resized, resized.Bounds(), source, source.Bounds(), draw.Src, nil)
|
||||
var output bytes.Buffer
|
||||
outputMime := "image/png"
|
||||
if resized.Opaque() {
|
||||
err = jpeg.Encode(&output, resized, &jpeg.Options{Quality: 82})
|
||||
outputMime = "image/jpeg"
|
||||
} else {
|
||||
err = png.Encode(&output, resized)
|
||||
}
|
||||
if err != nil {
|
||||
return original, err
|
||||
}
|
||||
if output.Len() >= len(encoded) && orientation == 1 {
|
||||
return original, nil
|
||||
}
|
||||
return assetPreviewResult{output.Bytes(), outputMime}, nil
|
||||
}
|
||||
|
||||
// JPEG EXIF orientation is applied to previews instead of silently rotating a
|
||||
// phone photograph after its EXIF metadata is removed by the image encoder.
|
||||
func jpegOrientation(data []byte) int {
|
||||
if len(data) < 4 || data[0] != 0xff || data[1] != 0xd8 {
|
||||
return 1
|
||||
}
|
||||
for at := 2; at+4 <= len(data); {
|
||||
if data[at] != 0xff {
|
||||
return 1
|
||||
}
|
||||
marker := data[at+1]
|
||||
if marker == 0xda || marker == 0xd9 {
|
||||
return 1
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(data[at+2 : at+4]))
|
||||
if length < 2 || at+2+length > len(data) {
|
||||
return 1
|
||||
}
|
||||
part := data[at+4 : at+2+length]
|
||||
if marker == 0xe1 && len(part) >= 14 && string(part[:6]) == "Exif\x00\x00" {
|
||||
tiff := part[6:]
|
||||
var order binary.ByteOrder
|
||||
switch string(tiff[:2]) {
|
||||
case "II":
|
||||
order = binary.LittleEndian
|
||||
case "MM":
|
||||
order = binary.BigEndian
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
if order.Uint16(tiff[2:4]) != 42 {
|
||||
return 1
|
||||
}
|
||||
offset := int(order.Uint32(tiff[4:8]))
|
||||
if offset < 8 || offset+2 > len(tiff) {
|
||||
return 1
|
||||
}
|
||||
count := int(order.Uint16(tiff[offset : offset+2]))
|
||||
for i := 0; i < count; i++ {
|
||||
entry := offset + 2 + i*12
|
||||
if entry+12 > len(tiff) {
|
||||
return 1
|
||||
}
|
||||
if order.Uint16(tiff[entry:entry+2]) == 0x112 && order.Uint16(tiff[entry+2:entry+4]) == 3 && order.Uint32(tiff[entry+4:entry+8]) == 1 {
|
||||
value := int(order.Uint16(tiff[entry+8 : entry+10]))
|
||||
if value >= 1 && value <= 8 {
|
||||
return value
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
at += 2 + length
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
type orientedAsset struct {
|
||||
image.Image
|
||||
orientation int
|
||||
}
|
||||
|
||||
func (im orientedAsset) Bounds() image.Rectangle {
|
||||
b := im.Image.Bounds()
|
||||
if im.orientation >= 5 {
|
||||
return image.Rect(0, 0, b.Dy(), b.Dx())
|
||||
}
|
||||
return image.Rect(0, 0, b.Dx(), b.Dy())
|
||||
}
|
||||
func (im orientedAsset) ColorModel() color.Model { return im.Image.ColorModel() }
|
||||
func (im orientedAsset) At(x, y int) color.Color {
|
||||
b := im.Image.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
switch im.orientation {
|
||||
case 2:
|
||||
x = w - 1 - x
|
||||
case 3:
|
||||
x, y = w-1-x, h-1-y
|
||||
case 4:
|
||||
y = h - 1 - y
|
||||
case 5:
|
||||
x, y = y, x
|
||||
case 6:
|
||||
x, y = y, h-1-x
|
||||
case 7:
|
||||
x, y = w-1-y, h-1-x
|
||||
case 8:
|
||||
x, y = w-1-y, x
|
||||
}
|
||||
return im.Image.At(b.Min.X+x, b.Min.Y+y)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssetPreviewDownscalesWithoutChangingOriginal(t *testing.T) {
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 1600, 900))
|
||||
for y := 0; y < 900; y++ {
|
||||
for x := 0; x < 1600; x++ {
|
||||
source.SetNRGBA(x, y, color.NRGBA{uint8(x), uint8(y), uint8(x * y), 255})
|
||||
}
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := append([]byte(nil), encoded.Bytes()...)
|
||||
preview, err := makeAssetPreview(encoded.Bytes(), "image/png", 512)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(preview.data))
|
||||
if err != nil || config.Width != 512 || config.Height != 288 || format != "jpeg" {
|
||||
t.Fatalf("bad preview %s %dx%d %v", format, config.Width, config.Height, err)
|
||||
}
|
||||
if len(preview.data) >= len(original) || !bytes.Equal(original, encoded.Bytes()) {
|
||||
t.Fatal("preview must shrink without changing original")
|
||||
}
|
||||
cached, err := cachedAssetPreview(context.Background(), original, "image/png", 512)
|
||||
if err != nil || !bytes.Equal(cached.data, preview.data) {
|
||||
t.Fatal("cache result mismatch")
|
||||
}
|
||||
}
|
||||
func TestAssetPreviewPreservesTransparencyAndAnimation(t *testing.T) {
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 800, 400))
|
||||
source.SetNRGBA(1, 1, color.NRGBA{255, 0, 0, 255})
|
||||
var encoded bytes.Buffer
|
||||
_ = png.Encode(&encoded, source)
|
||||
preview, err := makeAssetPreview(encoded.Bytes(), "image/png", 256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, format, err := image.Decode(bytes.NewReader(preview.data))
|
||||
if err != nil || format != "png" {
|
||||
t.Fatal("transparent image must stay PNG")
|
||||
}
|
||||
_, _, _, alpha := decoded.At(200, 100).RGBA()
|
||||
if alpha != 0 {
|
||||
t.Fatal("transparent background was lost")
|
||||
}
|
||||
original := []byte("GIF original animation bytes")
|
||||
gif, err := makeAssetPreview(original, "image/gif", 512)
|
||||
if err != nil || !bytes.Equal(gif.data, original) {
|
||||
t.Fatal("GIF animation changed")
|
||||
}
|
||||
}
|
||||
func TestAssetPreviewRejectsUnboundedWidths(t *testing.T) {
|
||||
for _, value := range []string{"0", "1", "256", "512", "2048", "999999", "-1", "abc"} {
|
||||
if _, err := previewWidth("preview", value); err == nil {
|
||||
t.Errorf("accepted width %s", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "1024"} {
|
||||
if _, err := previewWidth("preview", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n, err := previewWidth("", ""); err != nil || n != 0 {
|
||||
t.Fatal("original request changed")
|
||||
}
|
||||
if _, err := previewWidth("other", ""); err == nil {
|
||||
t.Fatal("unknown variant accepted")
|
||||
}
|
||||
if _, err := makeAssetPreview([]byte("invalid"), "image/png", 512); err == nil {
|
||||
t.Fatal("malformed image accepted")
|
||||
}
|
||||
}
|
||||
func TestAssetPreviewAppliesJPEGOrientation(t *testing.T) {
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 800, 400))
|
||||
for y := 0; y < 400; y++ {
|
||||
for x := 0; x < 800; x++ {
|
||||
source.SetNRGBA(x, y, color.NRGBA{255, 0, 0, 255})
|
||||
}
|
||||
}
|
||||
var original bytes.Buffer
|
||||
_ = jpeg.Encode(&original, source, nil)
|
||||
tiff := make([]byte, 26)
|
||||
copy(tiff, "II")
|
||||
binary.LittleEndian.PutUint16(tiff[2:], 42)
|
||||
binary.LittleEndian.PutUint32(tiff[4:], 8)
|
||||
binary.LittleEndian.PutUint16(tiff[8:], 1)
|
||||
binary.LittleEndian.PutUint16(tiff[10:], 0x112)
|
||||
binary.LittleEndian.PutUint16(tiff[12:], 3)
|
||||
binary.LittleEndian.PutUint32(tiff[14:], 1)
|
||||
binary.LittleEndian.PutUint16(tiff[18:], 6)
|
||||
exif := append([]byte("Exif\x00\x00"), tiff...)
|
||||
segment := append([]byte{0xff, 0xe1, 0, byte(len(exif) + 2)}, exif...)
|
||||
encoded := append(append(append([]byte{}, original.Bytes()[:2]...), segment...), original.Bytes()[2:]...)
|
||||
if jpegOrientation(encoded) != 6 {
|
||||
t.Fatal("EXIF orientation not decoded")
|
||||
}
|
||||
preview, err := makeAssetPreview(encoded, "image/jpeg", 256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(preview.data))
|
||||
if err != nil || config.Width != 128 || config.Height != 256 {
|
||||
t.Fatalf("rotated dimensions %dx%d %v", config.Width, config.Height, err)
|
||||
}
|
||||
}
|
||||
func TestAllAssetOrientationMappings(t *testing.T) {
|
||||
source := image.NewNRGBA(image.Rect(0, 0, 3, 2))
|
||||
source.SetNRGBA(0, 0, color.NRGBA{255, 0, 0, 255})
|
||||
for orientation := 1; orientation <= 8; orientation++ {
|
||||
oriented := orientedAsset{source, orientation}
|
||||
red := 0
|
||||
for y := 0; y < oriented.Bounds().Dy(); y++ {
|
||||
for x := 0; x < oriented.Bounds().Dx(); x++ {
|
||||
r, _, _, _ := oriented.At(x, y).RGBA()
|
||||
if r > 0 {
|
||||
red++
|
||||
}
|
||||
}
|
||||
}
|
||||
if red != 1 {
|
||||
t.Errorf("orientation %d loses/repeats pixels", orientation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,10 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
// Do not hold a workspace lock during a slow multipart upload. Recheck below.
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, assetLimit+(64<<10))
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
@@ -123,6 +127,19 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
|
||||
reject(w, 400, "invalid_asset")
|
||||
return nil
|
||||
}
|
||||
tx, err = s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, role, err = lockWorkspace(r.Context(), tx, project, workspace, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role == "viewer" {
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
digest := sha256.Sum256(encoded)
|
||||
hash := hex.EncodeToString(digest[:])
|
||||
info := assetInfo{MIME: mime, Bytes: len(encoded), Width: width, Height: height}
|
||||
@@ -204,6 +221,11 @@ func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, acco
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
width, variantErr := previewWidth(r.URL.Query().Get("variant"), r.URL.Query().Get("width"))
|
||||
if variantErr != nil {
|
||||
reject(w, 400, "invalid_asset_variant")
|
||||
return nil
|
||||
}
|
||||
id := r.PathValue("asset")
|
||||
if !uuidSlugPattern.MatchString(id) {
|
||||
reject(w, 404, "not_found")
|
||||
@@ -235,6 +257,21 @@ func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, acco
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Permission and immutable bytes are now read consistently. Release locks
|
||||
// before resizing or waiting for a slow public client to consume the body.
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
variant := "original"
|
||||
if width > 0 {
|
||||
preview, e := cachedAssetPreview(r.Context(), encoded, mime, width)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
encoded, mime = preview.data, preview.mime
|
||||
variant = "preview"
|
||||
}
|
||||
w.Header().Set("X-Asset-Variant", variant)
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(encoded)))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -345,6 +345,9 @@ func (s *service) canvas(w http.ResponseWriter, r *http.Request, project, accoun
|
||||
return nil
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("X-Canvas-Schema-Version", strconv.Itoa(canvasSchema(document)))
|
||||
setVersion(w, version)
|
||||
reply(w, 200, response{"version": version, "document": document})
|
||||
|
||||
@@ -24,6 +24,9 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
|
||||
reject(w, 400, "invalid_material")
|
||||
return nil
|
||||
}
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
models, modelErr := s.canvasAI.Models(ctx)
|
||||
@@ -44,8 +47,9 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
|
||||
"contract_version": "canvas-v2-2026-11-06",
|
||||
"supported_schema_versions": []int{1, 2},
|
||||
"edge_features": response{"appearance": true, "markers": true, "bezier_chain": true, "animation_configuration": true, "execution_events": true},
|
||||
"subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true},
|
||||
"subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true, "member_positions": true},
|
||||
"ai": ai,
|
||||
"assets": response{"previews": true, "preview_max_edge": 1024, "originals_preserved": true},
|
||||
"limits": response{"max_depth": 8, "max_nodes": 500, "max_edges": 2000, "max_frame_ports": 4000, "max_curve_knots": 64, "max_snapshot_bytes": canvasLimit, "max_active_tasks": 128, "max_run_steps": 64, "max_execution_depth": 32, "max_input_images": 4, "max_input_image_bytes": 20 << 20, "max_coordinate": 1000000},
|
||||
"retention": response{"idempotency_min_seconds": 86400, "undo_receipt_seconds": 604800},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Endpoints name real nodes, never transient projected copies or client-created ports.
|
||||
type canvasConnectionEndpoint struct {
|
||||
Board string `json:"board_id"`
|
||||
Node string `json:"node_id"`
|
||||
Handle string `json:"handle"`
|
||||
}
|
||||
type canvasConnectionInput struct {
|
||||
Source canvasConnectionEndpoint `json:"source"`
|
||||
Target canvasConnectionEndpoint `json:"target"`
|
||||
Edge struct {
|
||||
ID string `json:"id"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
} `json:"edge"`
|
||||
Expected map[string]int64 `json:"expected_versions"`
|
||||
}
|
||||
|
||||
// Paths run from a leaf board upwards to (but excluding) the coordinator.
|
||||
func connectionPath(r *http.Request, tx *sql.Tx, project, workspace, coordinator, board string) ([]subgraphRelation, error) {
|
||||
path := []subgraphRelation{}
|
||||
seen := map[string]bool{}
|
||||
for board != coordinator {
|
||||
if seen[board] || len(path) >= 8 {
|
||||
return nil, errGraphOwnership
|
||||
}
|
||||
seen[board] = true
|
||||
relation, err := readSubgraphRelation(r, tx, project, workspace, board)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, errGraphOwnership
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if relation.Archived {
|
||||
return nil, errGraphOwnership
|
||||
}
|
||||
path = append(path, relation)
|
||||
board = relation.Parent
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func connectionNode(doc map[string]any, id string) map[string]any {
|
||||
for _, value := range doc["nodes"].([]any) {
|
||||
node := value.(map[string]any)
|
||||
if node["id"] == id {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reuse ports by logical endpoint, including legacy ports with non-derived IDs.
|
||||
// A derived ID collision must never retarget an existing port.
|
||||
func connectionPort(data map[string]any, node, handle, direction string) (string, bool, error) {
|
||||
ports, _ := data["ports"].([]any)
|
||||
sum := sha256.Sum256([]byte(direction + "/" + node + "/" + handle))
|
||||
id := "p-" + hex.EncodeToString(sum[:16])
|
||||
collision := false
|
||||
for _, value := range ports {
|
||||
port := value.(map[string]any)
|
||||
if port["direction"] == direction && port["nodeId"] == node && port["handle"] == handle {
|
||||
return port["id"].(string), false, nil
|
||||
}
|
||||
collision = collision || port["id"] == id
|
||||
}
|
||||
if collision {
|
||||
return "", false, errGraphOwnership
|
||||
}
|
||||
if len(ports) >= 4000 {
|
||||
return "", false, errGraphCapacity
|
||||
}
|
||||
data["ports"] = append(ports, map[string]any{"id": id, "direction": direction, "nodeId": node, "handle": handle})
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
func exposeConnectionEndpoint(endpoint canvasConnectionEndpoint, direction string, path []subgraphRelation, documents map[string]map[string]any) (canvasConnectionEndpoint, []any, error) {
|
||||
node := connectionNode(documents[endpoint.Board], endpoint.Node)
|
||||
if node == nil {
|
||||
return endpoint, nil, errGraphOwnership
|
||||
}
|
||||
data := node["data"].(map[string]any)
|
||||
if !canvasChoice(data["kind"], "text", "script", "plain-text", "image", "plain-image") {
|
||||
return endpoint, nil, errGraphOwnership
|
||||
}
|
||||
created := []any{}
|
||||
for _, relation := range path {
|
||||
frame := connectionNode(documents[relation.Parent], relation.Frame)
|
||||
if frame == nil {
|
||||
return endpoint, nil, errGraphOwnership
|
||||
}
|
||||
data := frame["data"].(map[string]any)
|
||||
sub, _ := data["subgraph"].(map[string]any)
|
||||
if relation.Child != endpoint.Board || data["kind"] != "frame" || sub["boardId"] != relation.Child {
|
||||
return endpoint, nil, errGraphOwnership
|
||||
}
|
||||
id, added, err := connectionPort(data, endpoint.Node, endpoint.Handle, direction)
|
||||
if err != nil {
|
||||
return endpoint, nil, err
|
||||
}
|
||||
if added {
|
||||
created = append(created, response{"board_id": relation.Parent, "frame_id": relation.Frame, "port_id": id})
|
||||
}
|
||||
endpoint = canvasConnectionEndpoint{Board: relation.Parent, Node: relation.Frame, Handle: "port:" + id}
|
||||
}
|
||||
return endpoint, created, nil
|
||||
}
|
||||
|
||||
func (s *service) createCanvasConnection(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method != http.MethodPost {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
if !acceptsCanvasV2(r) {
|
||||
reject(w, 409, "canvas_schema_upgrade_required")
|
||||
return nil
|
||||
}
|
||||
key := canvasIdempotencyKey(r)
|
||||
if !uuidSlugPattern.MatchString(key) {
|
||||
reject(w, 400, "invalid_idempotency_key")
|
||||
return nil
|
||||
}
|
||||
var input canvasConnectionInput
|
||||
if !decode(w, r, &input) {
|
||||
return nil
|
||||
}
|
||||
for _, endpoint := range []canvasConnectionEndpoint{input.Source, input.Target} {
|
||||
if !uuidSlugPattern.MatchString(endpoint.Board) || !canvasID.MatchString(endpoint.Node) || !canvasChoice(endpoint.Handle, "top", "right", "bottom", "left") {
|
||||
reject(w, 400, "invalid_connection_request")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if !canvasID.MatchString(input.Edge.ID) {
|
||||
reject(w, 400, "invalid_edge")
|
||||
return nil
|
||||
}
|
||||
limits, _ := json.Marshal(response{"edges": []any{input.Edge}})
|
||||
if canvasExceededGraphLimits(limits) {
|
||||
reject(w, 413, "graph_limit_exceeded")
|
||||
return nil
|
||||
}
|
||||
if input.Edge.Data != nil && !validCanvasEdgeData(input.Edge.Data) {
|
||||
reject(w, 400, "invalid_edge")
|
||||
return nil
|
||||
}
|
||||
if len(input.Expected) == 0 {
|
||||
reject(w, 428, "version_required")
|
||||
return nil
|
||||
}
|
||||
workspace, coordinator := r.PathValue("workspace"), r.PathValue("material")
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, coordinator, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != "board" {
|
||||
reject(w, 400, "invalid_material")
|
||||
return nil
|
||||
}
|
||||
if role == "viewer" {
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
action, hash := "create_connection", payloadDigest(input)
|
||||
done, err := transactionRetry(w, r, tx, project, account, coordinator, action, key, hash)
|
||||
if err != nil || done {
|
||||
return err
|
||||
}
|
||||
participants := map[string]bool{coordinator: true}
|
||||
paths := [][]subgraphRelation{}
|
||||
for _, endpoint := range []canvasConnectionEndpoint{input.Source, input.Target} {
|
||||
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, endpoint.Board, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role == "viewer" {
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
if kind != "board" {
|
||||
reject(w, 400, "invalid_material")
|
||||
return nil
|
||||
}
|
||||
path, err := connectionPath(r, tx, project, workspace, coordinator, endpoint.Board)
|
||||
if err == errGraphOwnership {
|
||||
reject(w, 409, "ownership_conflict")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths = append(paths, path)
|
||||
participants[endpoint.Board] = true
|
||||
for _, relation := range path {
|
||||
participants[relation.Parent] = true
|
||||
}
|
||||
}
|
||||
// Require the lowest common ancestor, so the logical edge has one owner.
|
||||
left := map[string]bool{}
|
||||
for _, relation := range paths[0] {
|
||||
left[relation.Child] = true
|
||||
}
|
||||
for _, relation := range paths[1] {
|
||||
if left[relation.Child] {
|
||||
reject(w, 409, "connection_owner_mismatch")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
ids := []string{}
|
||||
for id := range participants {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
before := []canvasSnapshot{}
|
||||
snapshots := map[string]canvasSnapshot{}
|
||||
documents := map[string]map[string]any{}
|
||||
for _, id := range ids {
|
||||
_, role, err := lockMaterial(r.Context(), tx, project, workspace, id, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role == "viewer" {
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
snapshot, err := loadCanvas(r, tx, project, workspace, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshots[id] = snapshot
|
||||
before = append(before, snapshot)
|
||||
var doc map[string]any
|
||||
if err = json.Unmarshal(snapshot.Document, &doc); err != nil {
|
||||
return err
|
||||
}
|
||||
documents[id] = doc
|
||||
}
|
||||
if len(input.Expected) != len(ids) {
|
||||
reply(w, 428, response{"error": "version_required", "details": response{"required_board_ids": ids}})
|
||||
return nil
|
||||
}
|
||||
for _, id := range ids {
|
||||
expected, exists := input.Expected[id]
|
||||
if !exists {
|
||||
reply(w, 428, response{"error": "version_required", "details": response{"required_board_ids": ids}})
|
||||
return nil
|
||||
}
|
||||
if expected != snapshots[id].Version {
|
||||
reject(w, 412, "version_conflict")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, value := range documents[coordinator]["edges"].([]any) {
|
||||
if value.(map[string]any)["id"] == input.Edge.ID {
|
||||
reject(w, 409, "edge_id_conflict")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
mapped := []canvasConnectionEndpoint{}
|
||||
created := []any{}
|
||||
for i, endpoint := range []canvasConnectionEndpoint{input.Source, input.Target} {
|
||||
direction := "output"
|
||||
if i == 1 {
|
||||
direction = "input"
|
||||
}
|
||||
exposed, ports, err := exposeConnectionEndpoint(endpoint, direction, paths[i], documents)
|
||||
if err == errGraphOwnership {
|
||||
reject(w, 409, "ownership_conflict")
|
||||
return nil
|
||||
}
|
||||
if err == errGraphCapacity {
|
||||
reject(w, 413, "graph_limit_exceeded")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mapped = append(mapped, exposed)
|
||||
created = append(created, ports...)
|
||||
}
|
||||
edge := map[string]any{"id": input.Edge.ID, "source": mapped[0].Node, "sourceHandle": mapped[0].Handle, "target": mapped[1].Node, "targetHandle": mapped[1].Handle}
|
||||
if input.Edge.Data != nil {
|
||||
edge["data"] = input.Edge.Data
|
||||
}
|
||||
documents[coordinator]["edges"] = append(documents[coordinator]["edges"].([]any), edge)
|
||||
updated := []canvasSnapshot{}
|
||||
versions := map[string]int64{}
|
||||
for _, id := range ids {
|
||||
documents[id]["schema_version"] = 2
|
||||
raw, _ := json.Marshal(documents[id])
|
||||
if len(raw) > canvasLimit {
|
||||
reject(w, 413, "canvas_too_large")
|
||||
return nil
|
||||
}
|
||||
if canvasExceededGraphLimits(raw) {
|
||||
reject(w, 413, "graph_limit_exceeded")
|
||||
return nil
|
||||
}
|
||||
normalized, ok := normalizeCanvas(raw)
|
||||
if !ok {
|
||||
reject(w, 400, "invalid_canvas")
|
||||
return nil
|
||||
}
|
||||
snapshot := snapshots[id]
|
||||
snapshot.Document = normalized
|
||||
snapshot.Version++
|
||||
updated = append(updated, snapshot)
|
||||
snapshots[id] = snapshot
|
||||
versions[id] = snapshot.Version
|
||||
}
|
||||
// Nothing is persisted until both complete port chains and all snapshots validate.
|
||||
for _, snapshot := range updated {
|
||||
if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var id string
|
||||
var expiry time.Time
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text,now()+interval '7 days'`).Scan(&id, &expiry); err != nil {
|
||||
return err
|
||||
}
|
||||
result := response{"transaction_id": id, "parent": snapshots[coordinator], "snapshots": updated, "connection": response{"board_id": coordinator, "edge": edge, "source": input.Source, "target": input.Target}, "created_ports": created, "undo_receipt": response{"transaction_id": id, "valid_for_seconds": 604800, "expires_at": expiry}}
|
||||
encoded, _ := json.Marshal(result)
|
||||
prior, _ := json.Marshal(before)
|
||||
after, _ := json.Marshal(versions)
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_transactions(project,workspace_id,board_id,id,account_id,action,idempotency_key,payload_hash,response_status,response_body,before_state,after_versions) VALUES($1,$2,$3,$4,$5,$6,$7,$8,201,$9,$10,$11)`, project, workspace, coordinator, id, account, action, key, hash, encoded, prior, after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
setVersion(w, versions[coordinator])
|
||||
reply(w, 201, result)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanvasConnectionTransactions(t *testing.T) {
|
||||
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("requires an isolated integration database")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
var random [12]byte
|
||||
_, _ = rand.Read(random[:])
|
||||
project := "connections-" + hex.EncodeToString(random[:])
|
||||
defer func() {
|
||||
for _, table := range []string{"canvas_transactions", "canvas_subgraphs", "workspaces", "project_accounts"} {
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", project)
|
||||
}
|
||||
}()
|
||||
tokens := []string{}
|
||||
accounts := []string{}
|
||||
for range 3 {
|
||||
var account string
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text`, project).Scan(&account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value [32]byte
|
||||
_, _ = rand.Read(value[:])
|
||||
token := base64.RawURLEncoding.EncodeToString(value[:])
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
if _, err = db.ExecContext(ctx, `INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '1 hour')`, base64.RawURLEncoding.EncodeToString(hash[:]), project, account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
var workspace, root string
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Connections',$2) RETURNING id::text`, project, accounts[0]).Scan(&workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, role := range []string{"admin", "viewer"} {
|
||||
if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members VALUES($1,$2,$3,$4,now())`, project, workspace, accounts[i], role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Root',$3,$4) RETURNING id::text`, project, workspace, accounts[0], json.RawMessage(canvasFixture)).Scan(&root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, db, map[string]string{project: "https://app.example.com/auth/callback"}, "https://api.example.com", nil)
|
||||
base := "/projects/" + project + "/workspaces/" + workspace + "/materials/"
|
||||
path := func(board string) string { return base + board + "/canvas" }
|
||||
do := func(method, url, token string, body any, version int64, key string) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
r := httptest.NewRequest(method, url, bytes.NewReader(raw))
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("Accept", "application/vnd.foundry.canvas+json;version=2")
|
||||
if version > 0 {
|
||||
r.Header.Set("If-Match", strconv.Quote(strconv.FormatInt(version, 10)))
|
||||
}
|
||||
if key != "" {
|
||||
r.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
call := func(method, url, token string, body any, status int, version int64, key string) response {
|
||||
t.Helper()
|
||||
w := do(method, url, token, body, version, key)
|
||||
if w.Code != status {
|
||||
t.Fatalf("%s %s got %d %s want %d", method, url, w.Code, w.Body.String(), status)
|
||||
}
|
||||
out := response{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
key := func(n int) string { return fmt.Sprintf("a0000000-0000-4000-8000-%012d", n) }
|
||||
versions := func(ids ...string) map[string]int64 {
|
||||
t.Helper()
|
||||
out := map[string]int64{}
|
||||
for _, id := range ids {
|
||||
out[id] = int64(call("GET", path(id), tokens[0], nil, 200, 0, "")["version"].(float64))
|
||||
}
|
||||
return out
|
||||
}
|
||||
create := func(board, frame string, n int) string {
|
||||
t.Helper()
|
||||
v := versions(board)[board]
|
||||
result := call("POST", path(board)+"/subgraphs", tokens[0], response{"frame": response{"id": frame, "title": frame, "position": response{"x": 10, "y": 20}, "width": 600, "height": 400, "labelMode": "fixed"}}, 201, v, key(n))
|
||||
return result["child_board"].(map[string]any)["id"].(string)
|
||||
}
|
||||
seed := func(board string, nodes ...string) {
|
||||
t.Helper()
|
||||
values := []any{}
|
||||
for _, id := range nodes {
|
||||
values = append(values, response{"id": id, "type": "material", "position": response{"x": 10, "y": 20}, "data": response{"kind": "text", "title": id, "text": "preserved"}})
|
||||
}
|
||||
call("PATCH", path(board), tokens[0], response{"document": response{"schema_version": 2, "nodes": values, "edges": []any{}, "viewport": response{"x": 0, "y": 0, "zoom": 1}}}, 200, 1, "")
|
||||
}
|
||||
left := create(root, "left", 1)
|
||||
right := create(root, "right", 2)
|
||||
seed(left, "l1", "l2")
|
||||
seed(right, "r1")
|
||||
leaf := create(left, "nested", 3)
|
||||
seed(leaf, "deep")
|
||||
command := func(source, node, target, targetNode, id string, ids ...string) response {
|
||||
return response{"source": response{"board_id": source, "node_id": node, "handle": "right"}, "target": response{"board_id": target, "node_id": targetNode, "handle": "left"}, "edge": response{"id": id, "data": response{"appearance": response{"color": "#123456", "width": 2}, "path": response{"type": "bezier-chain", "knots": []any{response{"id": "k", "position": response{"x": 200, "y": 300}, "in": response{"x": -20, "y": 0}, "out": response{"x": 20, "y": 0}, "mode": "symmetric"}}}}}, "expected_versions": versions(ids...)}
|
||||
}
|
||||
firstBody := command(leaf, "deep", root, "a", "external", root, left, leaf)
|
||||
beforeLeaf := call("GET", path(leaf), tokens[0], nil, 200, 0, "")["document"]
|
||||
first := call("POST", path(root)+"/connections", tokens[0], firstBody, 201, 0, key(4))
|
||||
if len(first["created_ports"].([]any)) != 2 {
|
||||
t.Fatal("nested connection missing ports")
|
||||
}
|
||||
retry := call("POST", path(root)+"/connections", tokens[0], firstBody, 201, 0, key(4))
|
||||
if retry["transaction_id"] != first["transaction_id"] {
|
||||
t.Fatal("retry duplicated operation")
|
||||
}
|
||||
changedBody := command(leaf, "deep", root, "a", "different", root, left, leaf)
|
||||
call("POST", path(root)+"/connections", tokens[0], changedBody, 409, 0, key(4))
|
||||
afterLeaf := call("GET", path(leaf), tokens[0], nil, 200, 0, "")["document"]
|
||||
if payloadDigest(beforeLeaf) != payloadDigest(afterLeaf) {
|
||||
t.Fatal("connection changed leaf content")
|
||||
}
|
||||
parent := first["parent"].(map[string]any)
|
||||
edge := first["connection"].(map[string]any)["edge"].(map[string]any)
|
||||
if edge["source"] != "left" || edge["target"] != "a" || edge["data"].(map[string]any)["appearance"].(map[string]any)["color"] != "#123456" {
|
||||
t.Fatal("connection edge lost mapping/style")
|
||||
}
|
||||
if len(parent["document"].(map[string]any)["edges"].([]any)) != 2 {
|
||||
t.Fatal("existing edge lost")
|
||||
}
|
||||
undo := call("POST", path(root)+"/transactions/"+first["transaction_id"].(string)+"/undo", tokens[0], response{"expected_versions": versions(root, left, leaf)}, 200, 0, key(5))
|
||||
if len(undo["snapshots"].([]any)) != 3 {
|
||||
t.Fatal("undo did not restore complete chain")
|
||||
}
|
||||
for board, frame := range map[string]string{root: "left", left: "nested"} {
|
||||
document := call("GET", path(board), tokens[0], nil, 200, 0, "")["document"]
|
||||
raw, _ := json.Marshal(document)
|
||||
if len(canvasNodeData(raw)[frame]["ports"].([]any)) != 0 {
|
||||
t.Fatal("undo left ports behind")
|
||||
}
|
||||
}
|
||||
redoBody := command(leaf, "deep", root, "a", "external", root, left, leaf)
|
||||
redone := call("POST", path(root)+"/connections", tokens[0], redoBody, 201, 0, key(6))
|
||||
if payloadDigest(redone["connection"]) != payloadDigest(first["connection"]) {
|
||||
t.Fatal("redo changed stable ports")
|
||||
}
|
||||
reused := call("POST", path(root)+"/connections", tokens[0], command(leaf, "deep", root, "b", "second", root, left, leaf), 201, 0, key(7))
|
||||
if len(reused["created_ports"].([]any)) != 0 {
|
||||
t.Fatal("same source endpoint duplicated ports")
|
||||
}
|
||||
sibling := call("POST", path(root)+"/connections", tokens[0], command(left, "l1", right, "r1", "sibling", root, left, right), 201, 0, key(8))
|
||||
if len(sibling["created_ports"].([]any)) != 2 {
|
||||
t.Fatal("sibling connection missing boundary ports")
|
||||
}
|
||||
reverse := call("POST", path(root)+"/connections", tokens[0], command(root, "b", leaf, "deep", "reverse", root, left, leaf), 201, 0, key(9))
|
||||
if len(reverse["created_ports"].([]any)) != 2 {
|
||||
t.Fatal("input chain reused output ports")
|
||||
}
|
||||
// Missing/stale participant versions, bad endpoints and permission failures are atomic.
|
||||
stable := versions(root, left, leaf)
|
||||
missing := command(leaf, "deep", root, "a", "missing", root, left)
|
||||
required := call("POST", path(root)+"/connections", tokens[0], missing, 428, 0, key(10))
|
||||
if len(required["details"].(map[string]any)["required_board_ids"].([]any)) != 3 {
|
||||
t.Fatal("version response omitted participants")
|
||||
}
|
||||
stale := command(leaf, "deep", root, "a", "stale", root, left, leaf)
|
||||
stale["expected_versions"].(map[string]int64)[leaf]--
|
||||
call("POST", path(root)+"/connections", tokens[0], stale, 412, 0, key(11))
|
||||
call("POST", path(root)+"/connections", tokens[0], command(leaf, "absent", root, "a", "bad-node", root, left, leaf), 409, 0, key(12))
|
||||
call("POST", path(root)+"/connections", tokens[0], command(root, "icon", leaf, "deep", "icon-edge", root, left, leaf), 409, 0, key(13))
|
||||
call("POST", path(root)+"/connections", tokens[0], command(leaf, "deep", left, "l1", "wrong-owner", root, left, leaf), 409, 0, key(14))
|
||||
call("POST", path(root)+"/connections", tokens[0], command(leaf, "deep", root, "a", "external", root, left, leaf), 409, 0, key(15))
|
||||
call("POST", path(root)+"/connections", tokens[1], command(leaf, "deep", root, "a", "viewer", root, left, leaf), 403, 0, key(16))
|
||||
call("POST", path(root)+"/connections", tokens[2], command(leaf, "deep", root, "a", "outsider", root, left, leaf), 404, 0, key(17))
|
||||
invalid := command(leaf, "deep", root, "a", "handle", root, left, leaf)
|
||||
invalid["source"].(response)["handle"] = "port:fake"
|
||||
call("POST", path(root)+"/connections", tokens[0], invalid, 400, 0, key(18))
|
||||
if payloadDigest(stable) != payloadDigest(versions(root, left, leaf)) {
|
||||
t.Fatal("rejected transaction partially changed versions")
|
||||
}
|
||||
// The actual nested mapping is resolvable by the same reader used by AI propagation.
|
||||
graph := map[string]canvasSnapshot{}
|
||||
for _, board := range []string{root, left, leaf} {
|
||||
read := call("GET", path(board), tokens[0], nil, 200, 0, "")
|
||||
raw, _ := json.Marshal(read["document"])
|
||||
graph[board] = canvasSnapshot{BoardID: board, Version: int64(read["version"].(float64)), Document: raw}
|
||||
}
|
||||
reader := readerFromSnapshots(graph, map[string]int{})
|
||||
reader.snapshots = map[string]canvasSnapshot{}
|
||||
reader.documents = map[string]map[string]any{}
|
||||
reader.parents = map[string]subgraphRelation{}
|
||||
reader.parentKnown = map[string]bool{}
|
||||
resolved, err := reader.resolve(root, edge["source"].(string), edge["sourceHandle"].(string), "output", 0)
|
||||
if err != nil || resolved != canvasNodeKey(leaf, "deep") {
|
||||
t.Fatal("AI reader cannot resolve newly created port chain")
|
||||
}
|
||||
// Ordinary saves must still reject port retargeting.
|
||||
readParent := call("GET", path(root), tokens[0], nil, 200, 0, "")
|
||||
parentDoc := readParent["document"].(map[string]any)
|
||||
for _, value := range parentDoc["nodes"].([]any) {
|
||||
node := value.(map[string]any)
|
||||
if node["id"] == "left" {
|
||||
node["data"].(map[string]any)["ports"].([]any)[0].(map[string]any)["nodeId"] = "l2"
|
||||
}
|
||||
}
|
||||
call("PATCH", path(root), tokens[0], response{"document": parentDoc}, 409, int64(readParent["version"].(float64)), "")
|
||||
var otherWorkspace, otherBoard, unrelatedBoard string
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Other',$2) RETURNING id::text`, project, accounts[0]).Scan(&otherWorkspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members VALUES($1,$2,$3,'admin',now())`, project, otherWorkspace, accounts[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range []struct {
|
||||
workspace string
|
||||
id *string
|
||||
}{{otherWorkspace, &otherBoard}, {workspace, &unrelatedBoard}} {
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Other',$3,$4) RETURNING id::text`, project, target.workspace, accounts[0], json.RawMessage(canvasFixture)).Scan(target.id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
call("POST", path(root)+"/connections", tokens[0], command(left, "l1", otherBoard, "a", "cross-workspace", root, left), 404, 0, key(21))
|
||||
call("POST", path(root)+"/connections", tokens[0], command(left, "l1", unrelatedBoard, "a", "unrelated", root, left), 409, 0, key(22))
|
||||
legacyRequest := httptest.NewRequest("POST", path(root)+"/connections", bytes.NewReader([]byte("{}")))
|
||||
legacyRequest.Header.Set("Authorization", "Bearer "+tokens[0])
|
||||
legacyResponse := httptest.NewRecorder()
|
||||
mux.ServeHTTP(legacyResponse, legacyRequest)
|
||||
if legacyResponse.Code != 409 {
|
||||
t.Fatal("connection mutation accepted a legacy client")
|
||||
}
|
||||
caps := call("GET", path(root)+"/capabilities", tokens[0], nil, 200, 0, "")
|
||||
if caps["subgraphs"].(map[string]any)["connections"] != true {
|
||||
t.Fatal("capabilities omitted connection transaction")
|
||||
}
|
||||
// Four simultaneous retries create exactly one edge/receipt and advance versions once.
|
||||
concurrent := command(left, "l2", root, "a", "concurrent", root, left)
|
||||
prior := versions(root, left)
|
||||
start := make(chan struct{})
|
||||
results := make(chan *httptest.ResponseRecorder, 4)
|
||||
for range 4 {
|
||||
go func() { <-start; results <- do("POST", path(root)+"/connections", tokens[0], concurrent, 0, key(19)) }()
|
||||
}
|
||||
close(start)
|
||||
transaction := ""
|
||||
for range 4 {
|
||||
w := <-results
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("concurrent request: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var result response
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &result)
|
||||
id := result["transaction_id"].(string)
|
||||
if transaction != "" && transaction != id {
|
||||
t.Fatal("concurrent retry duplicated receipt")
|
||||
}
|
||||
transaction = id
|
||||
}
|
||||
for id, v := range versions(root, left) {
|
||||
if v != prior[id]+1 {
|
||||
t.Fatal("concurrent retry advanced version twice")
|
||||
}
|
||||
}
|
||||
// A full coordinator rejects the entire operation, including new leaf ports.
|
||||
snapshot := call("GET", path(root), tokens[0], nil, 200, 0, "")
|
||||
doc := snapshot["document"].(map[string]any)
|
||||
edges := []any{}
|
||||
for i := 0; i < 2000; i++ {
|
||||
edges = append(edges, response{"id": fmt.Sprint("full-", i), "source": "a", "target": "b", "sourceHandle": "right", "targetHandle": "left"})
|
||||
}
|
||||
doc["edges"] = edges
|
||||
call("PATCH", path(root), tokens[0], response{"document": doc}, 200, int64(snapshot["version"].(float64)), "")
|
||||
capacity := command(left, "l2", root, "a", "capacity", root, left)
|
||||
capacity["source"].(response)["handle"] = "top"
|
||||
beforeFailure := versions(root, left)
|
||||
beforeLeft := call("GET", path(left), tokens[0], nil, 200, 0, "")["document"]
|
||||
call("POST", path(root)+"/connections", tokens[0], capacity, 413, 0, key(20))
|
||||
afterLeft := call("GET", path(left), tokens[0], nil, 200, 0, "")["document"]
|
||||
if payloadDigest(beforeLeft) != payloadDigest(afterLeft) || payloadDigest(beforeFailure) != payloadDigest(versions(root, left)) {
|
||||
t.Fatal("capacity failure left ports or versions behind")
|
||||
}
|
||||
var receipts int
|
||||
if err = db.QueryRowContext(ctx, `SELECT count(*) FROM canvas_transactions WHERE project=$1 AND action='create_connection' AND idempotency_key=$2`, project, key(20)).Scan(&receipts); err != nil || receipts != 0 {
|
||||
t.Fatal("rejected connection left a receipt")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConnectionPortReuseCollisionAndCapacity(t *testing.T) {
|
||||
data := map[string]any{"ports": []any{map[string]any{"id": "custom-stable", "nodeId": "n", "handle": "left", "direction": "input"}}}
|
||||
id, created, err := connectionPort(data, "n", "left", "input")
|
||||
if err != nil || created || id != "custom-stable" {
|
||||
t.Fatal("existing logical port was not reused")
|
||||
}
|
||||
id, created, err = connectionPort(data, "n", "left", "output")
|
||||
if err != nil || !created || id == "custom-stable" {
|
||||
t.Fatal("input and output ports were conflated")
|
||||
}
|
||||
data["ports"].([]any)[1].(map[string]any)["nodeId"] = "other"
|
||||
if _, _, err = connectionPort(data, "n", "left", "output"); err != errGraphOwnership {
|
||||
t.Fatal("derived ID collision retargeted a port")
|
||||
}
|
||||
ports := []any{}
|
||||
for i := 0; i < 4000; i++ {
|
||||
ports = append(ports, map[string]any{"id": fmt.Sprint(i), "nodeId": "other", "handle": "left", "direction": "input"})
|
||||
}
|
||||
data["ports"] = ports
|
||||
if _, _, err = connectionPort(data, "n", "right", "output"); err != errGraphCapacity || len(data["ports"].([]any)) != 4000 {
|
||||
t.Fatal("port capacity partially mutated frame")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionExposureBuildsNestedDirectionChain(t *testing.T) {
|
||||
frame := func(child string) map[string]any {
|
||||
return map[string]any{"id": "frame", "data": map[string]any{"kind": "frame", "subgraph": map[string]any{"boardId": child}, "ports": []any{}}}
|
||||
}
|
||||
docs := map[string]map[string]any{
|
||||
"root": {"nodes": []any{frame("middle")}},
|
||||
"middle": {"nodes": []any{frame("leaf")}},
|
||||
"leaf": {"nodes": []any{map[string]any{"id": "n", "data": map[string]any{"kind": "plain-image", "src": "https://example.com/image.png"}}}},
|
||||
}
|
||||
before, _ := json.Marshal(docs["leaf"])
|
||||
path := []subgraphRelation{{Parent: "middle", Frame: "frame", Child: "leaf"}, {Parent: "root", Frame: "frame", Child: "middle"}}
|
||||
endpoint := canvasConnectionEndpoint{Board: "leaf", Node: "n", Handle: "bottom"}
|
||||
exposed, created, err := exposeConnectionEndpoint(endpoint, "output", path, docs)
|
||||
if err != nil || exposed.Board != "root" || exposed.Node != "frame" || len(created) != 2 {
|
||||
t.Fatalf("incomplete chain: %+v %v", exposed, err)
|
||||
}
|
||||
inner := connectionNode(docs["middle"], "frame")["data"].(map[string]any)["ports"].([]any)[0].(map[string]any)
|
||||
outer := connectionNode(docs["root"], "frame")["data"].(map[string]any)["ports"].([]any)[0].(map[string]any)
|
||||
if inner["nodeId"] != "n" || inner["handle"] != "bottom" || outer["nodeId"] != "frame" || outer["handle"] != "port:"+inner["id"].(string) {
|
||||
t.Fatal("outer port skipped a child boundary")
|
||||
}
|
||||
again, created, err := exposeConnectionEndpoint(endpoint, "output", path, docs)
|
||||
if err != nil || again != exposed || len(created) != 0 {
|
||||
t.Fatal("repeated exposure duplicated ports")
|
||||
}
|
||||
if _, created, err = exposeConnectionEndpoint(endpoint, "input", path, docs); err != nil || len(created) != 2 {
|
||||
t.Fatal("reverse direction did not create independent input chain")
|
||||
}
|
||||
after, _ := json.Marshal(docs["leaf"])
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("exposure modified the actual child node")
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func (s *service) undoCanvasTransaction(w http.ResponseWriter, r *http.Request,
|
||||
reject(w, 409, "transaction_already_undone")
|
||||
return nil
|
||||
}
|
||||
if originalAction != "create_subgraph" && originalAction != "migrate_subgraphs" && !strings.HasPrefix(originalAction, "dissolve_subgraph/") && !strings.HasPrefix(originalAction, "move_members/") {
|
||||
if originalAction != "create_subgraph" && originalAction != "migrate_subgraphs" && originalAction != "create_connection" && !strings.HasPrefix(originalAction, "dissolve_subgraph/") && !strings.HasPrefix(originalAction, "move_members/") {
|
||||
reject(w, 409, "undo_not_supported")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}/cancel", s.protected(callbacks, s.cancelCanvasAIRun))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}/apply", s.protected(callbacks, s.applyCanvasAIOutput))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/capabilities", s.protected(callbacks, s.canvasCapabilities))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/connections", s.protected(callbacks, s.createCanvasConnection))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs", s.protected(callbacks, s.createSubgraph))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs/migrate", s.protected(callbacks, s.migrateSubgraphs))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs/{frame}/dissolve", s.protected(callbacks, s.dissolveSubgraph))
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -19,7 +20,9 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
@@ -420,7 +423,8 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
}
|
||||
parentID := migrationBoard["id"].(string)
|
||||
memberPath := migrationPath + "/subgraphs/f/members"
|
||||
moveCommand := response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}
|
||||
moveCommand := response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": map[string]memberDropPosition{"a": {X: 40, Y: 50}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}
|
||||
call("POST", memberPath, tokens[0], response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": response{"wrong": response{"x": 1, "y": 2}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}, 400, "", accept, "eeeeeeee-1111-4111-8111-111111111111")
|
||||
movedMembers := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
||||
memberRetry := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
||||
if movedMembers["transaction_id"] != memberRetry["transaction_id"] {
|
||||
@@ -432,6 +436,10 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
targetRead := call("GET", targetCanvasPath, tokens[0], nil, 200, "", accept)
|
||||
sourceJSON, _ := json.Marshal(sourceRead["document"])
|
||||
targetJSON, _ := json.Marshal(targetRead["document"])
|
||||
dropPosition := canvasNodesByID(targetJSON)["a"]["position"].(map[string]any)
|
||||
if dropPosition["x"] != float64(140) || dropPosition["y"] != float64(250) {
|
||||
t.Fatalf("atomic drop geometry %v", dropPosition)
|
||||
}
|
||||
if canvasNodeData(sourceJSON)["a"] != nil || canvasNodeData(targetJSON)["a"] == nil {
|
||||
t.Fatal("member move duplicated or lost ownership")
|
||||
}
|
||||
@@ -530,6 +538,96 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
t.Fatal("asset bytes changed")
|
||||
}
|
||||
}
|
||||
// Warm the immutable preview cache, then verify it never bypasses permission.
|
||||
for _, sample := range []struct {
|
||||
token string
|
||||
status int
|
||||
}{{tokens[0], 200}, {tokens[1], 404}, {tokens[2], 404}} {
|
||||
request := httptest.NewRequest("GET", workspacePath+"/assets/"+uploaded["id"].(string)+"?variant=preview", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+sample.token)
|
||||
result := httptest.NewRecorder()
|
||||
mux.ServeHTTP(result, request)
|
||||
if result.Code != sample.status {
|
||||
t.Fatalf("preview permission got %d want %d", result.Code, sample.status)
|
||||
}
|
||||
if result.Code == 200 && (!bytes.Equal(result.Body.Bytes(), assetPNG.Bytes()) || result.Header().Get("Cache-Control") != "private, no-store") {
|
||||
t.Fatal("preview changed small original or privacy headers")
|
||||
}
|
||||
}
|
||||
// Neither a snapshot nor an asset response may keep the workspace locked
|
||||
// while a slow downstream consumer is accepting its body.
|
||||
for _, path := range []string{canvasPath, workspacePath + "/assets/" + uploaded["id"].(string)} {
|
||||
var probeErr error
|
||||
writer := &readLockProbe{ResponseRecorder: httptest.NewRecorder(), probe: func() {
|
||||
lockTx, e := db.BeginTx(ctx, nil)
|
||||
if e != nil {
|
||||
probeErr = e
|
||||
return
|
||||
}
|
||||
defer lockTx.Rollback()
|
||||
_, probeErr = lockTx.ExecContext(ctx, "SELECT id FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE NOWAIT", project, workspace)
|
||||
}}
|
||||
request := httptest.NewRequest("GET", path, nil)
|
||||
request.Header.Set("Authorization", "Bearer "+tokens[0])
|
||||
request.Header.Set("Accept", accept)
|
||||
mux.ServeHTTP(writer, request)
|
||||
if writer.Code != 200 || probeErr != nil {
|
||||
t.Fatalf("read held workspace lock during response: status=%d error=%v", writer.Code, probeErr)
|
||||
}
|
||||
}
|
||||
// A slow multipart body must not hold the workspace lock either.
|
||||
func() {
|
||||
reader, pipe := io.Pipe()
|
||||
defer reader.Close()
|
||||
defer pipe.Close()
|
||||
form := multipart.NewWriter(pipe)
|
||||
headers := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
unblock := func() { once.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
request := httptest.NewRequest("POST", workspacePath+"/assets", reader)
|
||||
request.Header.Set("Authorization", "Bearer "+tokens[0])
|
||||
request.Header.Set("Content-Type", form.FormDataContentType())
|
||||
request.Header.Set("Idempotency-Key", "99999999-9999-4999-8999-999999999998")
|
||||
result := httptest.NewRecorder()
|
||||
go func() { mux.ServeHTTP(result, request); close(done) }()
|
||||
go func() {
|
||||
part, e := form.CreateFormFile("image", "slow.png")
|
||||
close(headers)
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
<-release
|
||||
_, _ = part.Write(assetPNG.Bytes())
|
||||
_ = form.Close()
|
||||
_ = pipe.Close()
|
||||
}()
|
||||
select {
|
||||
case <-headers:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("upload did not consume headers")
|
||||
}
|
||||
lockTx, e := db.BeginTx(ctx, nil)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e = lockTx.ExecContext(ctx, "SELECT id FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE NOWAIT", project, workspace)
|
||||
_ = lockTx.Rollback()
|
||||
unblock()
|
||||
if e != nil {
|
||||
t.Fatalf("upload held workspace lock while receiving body: %v", e)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("upload did not finish")
|
||||
}
|
||||
if result.Code != 201 {
|
||||
t.Fatalf("slow upload failed: %d", result.Code)
|
||||
}
|
||||
}()
|
||||
download(tokens[0], 200)
|
||||
download(tokens[1], 404)
|
||||
download(tokens[2], 404)
|
||||
@@ -668,3 +766,19 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
t.Fatalf("click accounting incorrect %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A real SQL NOWAIT probe at the response-write boundary catches locks that
|
||||
// would otherwise remain invisible in a buffered httptest recorder.
|
||||
type readLockProbe struct {
|
||||
*httptest.ResponseRecorder
|
||||
probe func()
|
||||
}
|
||||
|
||||
func (w *readLockProbe) Write(body []byte) (int, error) {
|
||||
if w.probe != nil {
|
||||
probe := w.probe
|
||||
w.probe = nil
|
||||
probe()
|
||||
}
|
||||
return w.ResponseRecorder.Write(body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Positions are expressed in the source board's local coordinates. The existing
|
||||
// ownership transaction converts them into destination coordinates exactly once.
|
||||
type memberDropPosition struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
func validMemberDropPositions(ids []string, positions map[string]memberDropPosition) bool {
|
||||
if positions == nil {
|
||||
return true
|
||||
} // Backward-compatible old clients.
|
||||
if len(positions) != len(ids) {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
p, ok := positions[id]
|
||||
if !ok || seen[id] || math.IsNaN(p.X) || math.IsNaN(p.Y) || math.IsInf(p.X, 0) || math.IsInf(p.Y, 0) || math.Abs(p.X) > 1e6 || math.Abs(p.Y) > 1e6 {
|
||||
return false
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func positionMemberDrop(raw json.RawMessage, positions map[string]memberDropPosition) (json.RawMessage, error) {
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := 0
|
||||
nodes, ok := doc["nodes"].([]any)
|
||||
if !ok {
|
||||
return nil, errGraphOwnership
|
||||
}
|
||||
for _, v := range nodes {
|
||||
n, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errGraphOwnership
|
||||
}
|
||||
id, _ := n["id"].(string)
|
||||
if p, ok := positions[id]; ok {
|
||||
n["position"] = map[string]any{"x": p.X, "y": p.Y}
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != len(positions) {
|
||||
return nil, errGraphOwnership
|
||||
}
|
||||
return json.Marshal(doc)
|
||||
}
|
||||
|
||||
func (p *memberDropPosition) UnmarshalJSON(raw []byte) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fields) != 2 || fields["x"] == nil || fields["y"] == nil || string(fields["x"]) == "null" || string(fields["y"]) == "null" {
|
||||
return fmt.Errorf("invalid member position")
|
||||
}
|
||||
if err := json.Unmarshal(fields["x"], &p.X); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(fields["y"], &p.Y)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemberDropPositions(t *testing.T) {
|
||||
raw := memberFixture(t)
|
||||
source := "11111111-1111-4111-8111-111111111111"
|
||||
target := "22222222-2222-4222-8222-222222222222"
|
||||
original := string(raw[source])
|
||||
positions := map[string]memberDropPosition{"a": {X: 40, Y: 50}}
|
||||
positioned, err := positionMemberDrop(raw[source], positions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw[source] = positioned
|
||||
moved, err := moveMembersDocuments("parent", source, target, []string{"a"}, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pos := canvasNodesByID(moved[target])["a"]["position"].(map[string]any)
|
||||
if pos["x"] != float64(140) || pos["y"] != float64(250) {
|
||||
t.Fatalf("final drop position %v", pos)
|
||||
}
|
||||
if original == string(positioned) {
|
||||
t.Fatal("position not applied")
|
||||
}
|
||||
if _, err := positionMemberDrop(positioned, map[string]memberDropPosition{"missing": {}}); err == nil {
|
||||
t.Fatal("missing member accepted")
|
||||
}
|
||||
if !validMemberDropPositions([]string{"a"}, nil) || !validMemberDropPositions([]string{"a"}, positions) {
|
||||
t.Fatal("valid positions rejected")
|
||||
}
|
||||
for _, bad := range []map[string]memberDropPosition{{}, {"other": {}}, {"a": {X: 1e6 + 1}}, {"a": {Y: math.NaN()}}, {"a": {X: math.Inf(1)}}} {
|
||||
if validMemberDropPositions([]string{"a"}, bad) {
|
||||
t.Fatalf("bad positions accepted %v", bad)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{`{"x":1}`, `{"x":null,"y":1}`, `{"x":1,"y":2,"z":3}`} {
|
||||
var p memberDropPosition
|
||||
if json.Unmarshal([]byte(bad), &p) == nil {
|
||||
t.Fatal("malformed position accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,11 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr
|
||||
return nil
|
||||
}
|
||||
var input struct {
|
||||
Source string `json:"source_board_id"`
|
||||
Target string `json:"target_board_id"`
|
||||
Nodes []string `json:"node_ids"`
|
||||
Expected map[string]int64 `json:"expected_versions"`
|
||||
Source string `json:"source_board_id"`
|
||||
Target string `json:"target_board_id"`
|
||||
Nodes []string `json:"node_ids"`
|
||||
Expected map[string]int64 `json:"expected_versions"`
|
||||
Positions map[string]memberDropPosition `json:"positions,omitempty"`
|
||||
}
|
||||
if !decode(w, r, &input) {
|
||||
return nil
|
||||
@@ -40,6 +41,10 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if !validMemberDropPositions(input.Nodes, input.Positions) {
|
||||
reject(w, 400, "invalid_subgraph_request")
|
||||
return nil
|
||||
}
|
||||
if len(input.Expected) == 0 {
|
||||
reject(w, 428, "version_required")
|
||||
return nil
|
||||
@@ -194,6 +199,14 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr
|
||||
before = append(before, snapshot)
|
||||
}
|
||||
raw := map[string]json.RawMessage{parent: snapshots[parent].Document, input.Source: snapshots[input.Source].Document, input.Target: snapshots[input.Target].Document}
|
||||
if input.Positions != nil {
|
||||
positioned, err := positionMemberDrop(raw[input.Source], input.Positions)
|
||||
if err != nil {
|
||||
reject(w, 409, "ownership_conflict")
|
||||
return nil
|
||||
}
|
||||
raw[input.Source] = positioned
|
||||
}
|
||||
moved, err := moveMembersDocuments(parent, input.Source, input.Target, input.Nodes, raw)
|
||||
if err != nil {
|
||||
if err == errGraphOwnership {
|
||||
|
||||
Reference in New Issue
Block a user