Skip to content
Draft
656 changes: 656 additions & 0 deletions docs/superpowers/plans/2026-08-07-ngviews-02-api.md

Large diffs are not rendered by default.

46 changes: 30 additions & 16 deletions docs/superpowers/specs/2026-08-07-neuroglancer-views-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,17 +147,29 @@ the backend only stores it — no server-side NG state generation.
| `PUT /api/neuroglancer/views/{short_key}` | Owner rename / metadata update. |
| `DELETE /api/neuroglancer/views/{short_key}` | Delete an owned View. |
| `GET /ngview/{key}` | Resolve a View by **read_key**; serve `ng_state` JSON for the NG iframe (mirrors `/ng/{short_key}`, `Cache-Control: no-store`). |
| `GET /api/proxied-path/{sharing_key}/views` | Dependent Views for a Data Link (powers the delete dialog + "Appears in N Views"). |

Data Link deletion (`DELETE /api/proxied-path/{sharing_key}`) gains a mode
parameter — `mark_broken` or `cascade`:

- `mark_broken`: null `data_link_id` and set `broken = true` on each dependent
`view_layer`, then delete the link. Views survive, degraded.
- `cascade`: delete the dependent Views (and their layers), then delete the link.

The frontend queries dependents first and drives the choice through one dialog
(see §7).
| `GET /api/proxied-path/{sharing_key}/views` | The **caller's own** dependent Views for a Data Link (powers the delete dialog + "Appears in N Views"). Owner-scoped — never lists other users' Views. |

Data Link deletion (`DELETE /api/proxied-path/{sharing_key}`) takes a
`confirm` boolean and **never cascade-deletes Views** (decided against
cross-user data loss):

- If the caller has **their own** dependent Views and `confirm` is false →
**409** with the list of *the caller's own* Views that will break (no other
user's Views are ever disclosed).
- On `confirm=true` (or when the caller has no own dependents) → null
`data_link_id` + set `broken = true` on **all** layers on that link (any
owner, for referential integrity — other users' Views degrade gracefully and
surface as broken when opened), then delete the link.

`sharing_mode` is **not enforced** in this PR — every View is readable by its
`read_key` (bearer token); `'private'` is a stored label only. A future PR adds
`'public'` (unauthenticated / listed) viewing and real per-mode enforcement.
Other users learn a shared Data Link broke lazily (the proxied path 404s / the
layer's `broken` flag); a **follow-up** may surface a broken indicator in their
Data Links table.

The frontend queries the caller's own dependents first and drives the confirm
through one dialog (see §7).

Read-key sessions never write to the database. Owner CRUD above is
authenticated as the owner and is not an "edit link" — it is basic management,
Expand Down Expand Up @@ -222,8 +234,10 @@ and stays in read-only scope.
their local tweaks — client-side only, never written to the DB.
- **Selection granularity**: dataset + channel (two levels). Channels load
lazily on expand. Maps onto the existing channel-per-layer code.
- **Data Link deletion**: one dialog — list dependent Views, user picks
{mark broken | delete those Views}, or Cancel.
- **Data Link deletion**: one dialog — list the caller's **own** dependent
Views that will break, user picks {Confirm (mark my Views broken) | Cancel}.
No cascade-delete; no cross-user disclosure (revised from an earlier
mark-broken/delete/cascade design — see §5).
- **Scratch View lifetime**: client-only until saved.

## 8. The `gh stack` — six bottom-up PRs
Expand All @@ -249,9 +263,9 @@ persist-on-change, the amber edit banner, the In-View Data Panel, and
## 9. Testing

- **Backend** (`pixi run -e test test-backend`): model + migration round-trip;
Views CRUD; `GET /ngview/{key}` read-key resolution and 404s; dependent-views
query; both Data Link delete modes (mark-broken nulls the link + flags layers;
cascade removes Views).
Views CRUD; `GET /ngview/{key}` read-key resolution and 404s; owner-scoped
dependent-views query; Data Link delete confirm-guard (409 lists only the
caller's own Views; confirm marks all layers on the link broken + deletes it).
- **Frontend unit** (`pixi run test-frontend`): `viewQueries` / `CartContext`
reducers; Export menu URL construction; multi-select selection logic;
consent-gate branching on `areDataLinksAutomatic`.
Expand Down
27 changes: 21 additions & 6 deletions fileglancer/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -992,7 +992,7 @@ def create_view(


def get_view_by_short_key(session: Session, short_key: str) -> Optional[ViewDB]:
"""Get an owned View by its short key."""
"""Get a View by its short key. No owner filter — callers scope ownership."""
return session.query(ViewDB).filter_by(short_key=short_key).first()


Expand Down Expand Up @@ -1041,15 +1041,30 @@ def delete_view(session: Session, username: str, short_key: str) -> int:
return 1


def get_views_for_data_link(session: Session, data_link_id: int) -> List[ViewDB]:
"""Distinct Views that have at least one layer backed by this Data Link."""
return (
def get_views_for_data_link(session: Session, data_link_id: int, owner: Optional[str] = None) -> List[ViewDB]:
"""Distinct Views that have at least one layer backed by this Data Link.
If `owner` is given, restrict to Views owned by that user (used to avoid
disclosing other users' Views when guarding a Data Link deletion)."""
query = (
session.query(ViewDB)
.join(ViewLayerDB, ViewLayerDB.view_id == ViewDB.id)
.filter(ViewLayerDB.data_link_id == data_link_id)
.distinct()
.all()
)
if owner is not None:
query = query.filter(ViewDB.owner == owner)
return query.distinct().all()


def mark_view_layers_broken(session: Session, data_link_id: int) -> int:
"""Detach a Data Link from all View layers that use it: null the
data_link_id and set broken=True. Returns the number of layers updated.
Leaves the Views themselves intact (degraded)."""
layers = session.query(ViewLayerDB).filter_by(data_link_id=data_link_id).all()
for layer in layers:
layer.data_link_id = None
layer.broken = True
session.commit()
return len(layers)


def get_tickets(session: Session, username: str, fsp_name: str = None, path: str = None) -> List[TicketDB]:
Expand Down
26 changes: 25 additions & 1 deletion fileglancer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ class View(BaseModel):
read_key: str = Field(description="Key that opens this View read-only")
name: str = Field(description="Display name of the View")
ng_state: Dict = Field(description="The Neuroglancer state JSON")
sharing_mode: str = Field(description="'private' or 'read'")
sharing_mode: Literal['private', 'read'] = Field(description="'private' or 'read'")
owner: str = Field(description="Username of the View owner")
created_at: datetime = Field(description="When this View was created")
updated_at: datetime = Field(description="When this View was last updated")
Expand All @@ -199,6 +199,30 @@ class ViewResponse(BaseModel):
views: List[View] = Field(description="A list of Neuroglancer Views")


class ViewLayerInput(BaseModel):
"""One layer in a create-View request. `sharing_key` names the Data Link
that backs this layer (resolved to an internal id server-side); null for a
layer with no Fileglancer Data Link (e.g. an external URL layer)."""
sharing_key: Optional[str] = Field(default=None, description="Data Link sharing key backing this layer")
layer_index: int = Field(description="Position of this layer within the View")
channel: Optional[str] = Field(default=None, description="Channel identifier, if this layer is one channel")
opts: Optional[Dict] = Field(default=None, description="Per-layer options")


class ViewCreateRequest(BaseModel):
"""Request body for creating a View. The client builds `ng_state`."""
name: str = Field(description="Display name of the View")
ng_state: Dict = Field(description="The Neuroglancer state JSON")
sharing_mode: Literal['private', 'read'] = Field(default='read', description="'private' or 'read'")
layers: List[ViewLayerInput] = Field(default_factory=list, description="Layers backing this View")


class ViewUpdateRequest(BaseModel):
"""Request body for an owner update (rename / restate)."""
name: Optional[str] = Field(default=None, description="New display name")
ng_state: Optional[Dict] = Field(default=None, description="Replacement Neuroglancer state JSON")


class ExternalBucket(BaseModel):
"""An external bucket for S3-compatible storage"""
id: int = Field(
Expand Down
106 changes: 104 additions & 2 deletions fileglancer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,18 @@ async def get_proxied_path(sharing_key: str = Path(..., description="The sharing
return _convert_proxied_path(path, settings.external_proxy_url)


@app.get("/api/proxied-path/{sharing_key}/views", response_model=ViewResponse,
description="List Neuroglancer Views that depend on this Data Link")
async def get_views_for_proxied_path(sharing_key: str = Path(..., description="The sharing key of the proxied path"),
username: str = Depends(get_current_user)):
with db.get_db_session(settings.db_url) as session:
pp = db.get_proxied_path_by_sharing_key(session, sharing_key)
if not pp or pp.username != username:
raise HTTPException(status_code=404, detail="Proxied path not found")
views = db.get_views_for_data_link(session, pp.id, owner=username)
return ViewResponse(views=[View.model_validate(v) for v in views])


@app.put("/api/proxied-path/{sharing_key}", description="Update a proxied path by sharing key")
async def update_proxied_path(sharing_key: str = Path(..., description="The sharing key of the proxied path"),
fsp_name: Optional[str] = Query(default=None, description="The name of the file share path that this proxied path is associated with"),
Expand Down Expand Up @@ -1246,11 +1258,25 @@ async def update_proxied_path(sharing_key: str = Path(..., description="The shar

@app.delete("/api/proxied-path/{sharing_key}", description="Delete a proxied path by sharing key")
async def delete_proxied_path(sharing_key: str = Path(..., description="The sharing key of the proxied path"),
confirm: bool = Query(False, description="Confirm deletion even though it breaks the caller's own Views"),
username: str = Depends(get_current_user)):
with db.get_db_session(settings.db_url) as session:
deleted = db.delete_proxied_path(session, username, sharing_key)
if deleted == 0:
pp = db.get_proxied_path_by_sharing_key(session, sharing_key)
if not pp or pp.username != username:
raise HTTPException(status_code=404, detail="Proxied path not found")
# Disclose only the caller's OWN dependent Views (never leak others').
own_dependents = db.get_views_for_data_link(session, pp.id, owner=username)
if own_dependents and not confirm:
# ponytail: JSONResponse (not HTTPException) so the structured detail
# survives the app-wide handler at server.py:~614 that stringifies dict details.
return JSONResponse(status_code=409, content={"detail": {
"message": "This data link backs Neuroglancer Views you own; they will be marked broken.",
"dependent_views": [{"short_key": v.short_key, "name": v.name} for v in own_dependents],
}})
# Mark ALL layers on this link broken (any owner) for referential integrity,
# so other users' Views degrade gracefully without disclosing them here.
db.mark_view_layers_broken(session, pp.id)
db.delete_proxied_path(session, username, sharing_key)
return {"message": f"Proxied path {sharing_key} deleted for user {username}"}


Expand Down Expand Up @@ -1278,6 +1304,18 @@ async def get_neuroglancer_state(short_key: str = Path(..., description="Short k
return JSONResponse(content=entry.state, headers={"Cache-Control": "no-store"})


# ponytail: sharing_mode is not enforced here — every View is readable by its
# read_key (bearer token). A future PR adds "public" (unauthenticated/listed)
# vs owner-only semantics; until then sharing_mode is a stored label only.
@app.get("/ngview/{key}", name="get_view_state", include_in_schema=False)
async def get_view_state(key: str = Path(..., description="A View's read key")):
with db.get_db_session(settings.db_url) as session:
view = db.get_view_by_read_key(session, key)
if not view:
raise HTTPException(status_code=404, detail="View not found")
return JSONResponse(content=view.ng_state, headers={"Cache-Control": "no-store"})


@app.get("/api/neuroglancer/nglinks", response_model=NeuroglancerShortLinkResponse,
description="List stored Neuroglancer short links for the current user")
async def get_neuroglancer_short_links(request: Request,
Expand Down Expand Up @@ -1309,6 +1347,70 @@ async def get_neuroglancer_short_links(request: Request,
return NeuroglancerShortLinkResponse(links=links)


@app.post("/api/neuroglancer/views", response_model=View,
description="Create a Neuroglancer View from a client-built state and layer list")
async def create_view_endpoint(payload: ViewCreateRequest,
username: str = Depends(get_current_user)):
with db.get_db_session(settings.db_url) as session:
layers = []
for layer in payload.layers:
data_link_id = None
if layer.sharing_key:
pp = db.get_proxied_path_by_sharing_key(session, layer.sharing_key)
if not pp:
raise HTTPException(status_code=400,
detail=f"Unknown data link sharing key: {layer.sharing_key}")
data_link_id = pp.id
layers.append({
"data_link_id": data_link_id,
"layer_index": layer.layer_index,
"channel": layer.channel,
"opts": layer.opts,
})
view = db.create_view(session, username, payload.name, payload.ng_state,
layers, payload.sharing_mode)
return View.model_validate(view)

@app.get("/api/neuroglancer/views", response_model=ViewResponse,
description="List the current user's Neuroglancer Views")
async def list_views_endpoint(username: str = Depends(get_current_user)):
with db.get_db_session(settings.db_url) as session:
views = db.get_views(session, username)
return ViewResponse(views=[View.model_validate(v) for v in views])

@app.get("/api/neuroglancer/views/{short_key}", response_model=View,
description="Get one of the current user's Neuroglancer Views")
async def get_view_endpoint(short_key: str = Path(..., description="The View's short key"),
username: str = Depends(get_current_user)):
with db.get_db_session(settings.db_url) as session:
view = db.get_view_by_short_key(session, short_key)
if not view or view.owner != username:
raise HTTPException(status_code=404, detail="View not found")
return View.model_validate(view)

@app.put("/api/neuroglancer/views/{short_key}", response_model=View,
description="Update (rename / restate) one of the current user's Views")
async def update_view_endpoint(payload: ViewUpdateRequest,
short_key: str = Path(..., description="The View's short key"),
username: str = Depends(get_current_user)):
with db.get_db_session(settings.db_url) as session:
view = db.update_view(session, username, short_key,
name=payload.name, ng_state=payload.ng_state)
if not view:
raise HTTPException(status_code=404, detail="View not found")
return View.model_validate(view)

@app.delete("/api/neuroglancer/views/{short_key}",
description="Delete one of the current user's Views")
async def delete_view_endpoint(short_key: str = Path(..., description="The View's short key"),
username: str = Depends(get_current_user)):
with db.get_db_session(settings.db_url) as session:
deleted = db.delete_view(session, username, short_key)
if deleted == 0:
raise HTTPException(status_code=404, detail="View not found")
return {"message": f"View {short_key} deleted"}


@app.get("/files/{sharing_key}/{path:path}")
async def target_dispatcher(request: Request,
sharing_key: str,
Expand Down
57 changes: 57 additions & 0 deletions tests/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,40 @@ def test_get_views_for_data_link(db_session):
assert get_views_for_data_link(db_session, 999) == []


def test_mark_view_layers_broken(db_session):
layers = [
{"data_link_id": 7, "layer_index": 0, "channel": None, "opts": None},
{"data_link_id": 7, "layer_index": 1, "channel": "Ch1", "opts": None},
{"data_link_id": 8, "layer_index": 2, "channel": None, "opts": None},
]
v = create_view(db_session, "u", "mixed", {"layers": []}, layers, "read")

updated = mark_view_layers_broken(db_session, 7)
assert updated == 2

db_session.refresh(v)
by_index = {l.layer_index: l for l in v.layers}
assert by_index[0].data_link_id is None and by_index[0].broken is True
assert by_index[1].data_link_id is None and by_index[1].broken is True
# the data_link_id=8 layer is untouched
assert by_index[2].data_link_id == 8 and by_index[2].broken is False
# the View itself still exists
assert get_view_by_short_key(db_session, v.short_key) is not None


def test_get_views_for_data_link_owner_filter(db_session):
layer = [{"data_link_id": 11, "layer_index": 0, "channel": None, "opts": None}]
mine = create_view(db_session, "me", "mine", {"layers": []}, layer, "read")
create_view(db_session, "other", "theirs", {"layers": []}, layer, "read")

# unfiltered: both owners' views
all_deps = get_views_for_data_link(db_session, 11)
assert {v.owner for v in all_deps} == {"me", "other"}
# owner-scoped: only mine
mine_only = get_views_for_data_link(db_session, 11, owner="me")
assert [v.short_key for v in mine_only] == [mine.short_key]


def test_view_pydantic_from_orm(db_session):
from fileglancer.model import View
layers = [{"data_link_id": 7, "layer_index": 0, "channel": "Ch0", "opts": None}]
Expand Down Expand Up @@ -808,3 +842,26 @@ def test_no_matching_candidate_returns_none(self):
)
assert result is None


def test_view_request_models_validate_sharing_mode():
from pydantic import ValidationError
from fileglancer.model import ViewCreateRequest, ViewLayerInput

req = ViewCreateRequest(
name="demo",
ng_state={"layers": []},
sharing_mode="read",
layers=[ViewLayerInput(sharing_key="abc", layer_index=0)],
)
assert req.sharing_mode == "read"
assert req.layers[0].sharing_key == "abc"
assert req.layers[0].channel is None

# default sharing_mode
assert ViewCreateRequest(name="d", ng_state={}).sharing_mode == "read"

# invalid sharing_mode is rejected at the boundary
import pytest
with pytest.raises(ValidationError):
ViewCreateRequest(name="d", ng_state={}, sharing_mode="public")

Loading
Loading