STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
documents.py
Go to the documentation of this file.
1"""Per-user designs with sharing, microversion history and named releases.
2
3The three design tools -- EngineDesign, pid-designer, recovery-calculator --
4share this router. They differ in only four things, all of them
5:class:`DesignStore` fields: the ``<app>`` path segment, the route prefix, the
6payload shape (``{"config": ...}`` vs ``{"nodes": ..., "edges": ...}``), and the
7noun used in user-facing strings.
8
9Three tiers of durability:
10
11* **autosave** -> a fast working copy on the volume (``current.json``). Never
12 hits S3. ``/load`` always returns it -- the freshest state.
13* **microversions** -> automatic snapshots pushed to storage over time
14 (throttled to the store's ``micro_interval`` while editing) plus a best-effort
15 ``/flush`` when the tab closes. The safety net; S3 object versioning + a
16 lifecycle rule prunes old ones in prod, files on the volume in dev.
17* **releases** -> explicit, immutable, user-named milestones ("0.1").
18
19Version history lives in :mod:`stardesign.storage`. This module owns the working
20copy and the per-user document index only. Auth is Caddy's job; a missing header
21just means the ``local`` user (never a rejection). See :mod:`stardesign.userdata`.
22
23Sharing
24-------
25A design lives in its creator's folder, but that is only *where it lives* -- it
26is not a privilege level. The creator and everyone in the record's ``sharedWith``
27list are equally editors; there is no owner-only operation. Any design can also
28be read and copied by anyone, which is what the view-only tree in the UI browses.
29
30Every design-scoped handler resolves through ``_resolve_doc``, which is the one
31place cross-user access is granted -- and, since this module is shared, the one
32place in the whole fleet. It matters that it returns a :class:`DocRef` rather
33than a directory name: the write helpers below happily ``mkdir`` whatever path
34they are handed, so a handler that *forgot* ``?owner=`` would not 403 -- it would
35silently write a real ``current.json`` into the caller's own folder with no index
36entry behind it, and the edit would vanish on reload. ``/flush`` rides a
37``sendBeacon`` whose response nobody reads, so that failure would be completely
38silent on the most loss-prone path. A ``DocRef`` only exists if an index record
39was found, so that cannot happen.
40
41Checkouts
42---------
43Concurrent editing is not resolved, it is prevented. At most one person holds a
44design's write token at a time, and only the holder may save. Taking it is
45explicit (opening a design never takes it, so viewing never blocks a colleague);
46it lapses on its own after ``lock_ttl`` without a save, and on tab close.
47Hiding the tab -- switching away, minimising, closing a lid -- does not release
48it; only a real close does.
49
50A lapse frees the design for whoever acts first. If that is the holder -- they
51came back and saved or touched the canvas, and nobody took it in the meantime --
52the hold is simply theirs again (``DesignStore.claim_for_write``); refusing
53them was how edits were lost with nobody else anywhere near the design. If
54someone else got there first, the holder's saves are refused, and ``/rescue``
55keeps what they carried as a new design of the holder's own.
56
57The compare-and-set runs inside ``_index_lock``, the same ``flock`` that already
58serialises index writes, so two simultaneous takes cannot both succeed. That
59holds across the several workers each API runs.
60
61**A constraint worth knowing:** ``flock`` is per-machine. All three design tools
62run on the one apps machine sharing the ``userdata`` volume, so this is sound
63today. Running a design tool on a second machine against the same volume would
64break the guarantee silently, and needs a different primitive.
65
66Open to all, and the main design
67--------------------------------
68Opt-in modes, all off by default so the apps that do not set them behave
69exactly as before (pid-designer turns both on):
70
71* ``open_to_all`` -- every design is editable by everyone; checkouts are what
72 stop two people saving at once. ``recent_days`` then keeps the editable list
73 short: other people's designs not updated within that window move to
74 ``/browse``, where they can still be opened. Your own never age out.
75* ``featured`` -- an admin may mark one design as the team's main one. Everyone
76 can open it, it heads every list and the window never hides it, and **only an
77 admin may change it** (unless ``main_admin_only`` is off): everyone else takes
78 a copy. Admins come from ``is_admin`` (see :mod:`stardesign.admins`); the
79 default is nobody.
80
81The STAR collection
82-------------------
83``curated`` is the third mode, and the one pid-designer runs (with
84``open_to_all`` off and ``main_admin_only`` off):
85
86* Admins choose the **STAR set** (``PUT/DELETE /{id}/star``, kept in the
87 app's ``star.json``). Everyone sees those; the main design is always one.
88* Everyone else sees their own designs and those shared with them. **Admins see
89 everything**, and may edit everything.
90* Editing is the creator, the share list, and admins. Only the creator or an
91 admin may change the share list -- here it is a real boundary, not
92 housekeeping.
93* Anyone who can see a design may **ask to edit** it (``POST /{id}/access``);
94 the creator or an admin approves (they join the share list) or denies.
95 ``GET /requests`` lists what the caller may answer.
96* "Can read" narrows to "can see": nobody copies a design they cannot open.
97
98There is deliberately no delete endpoint: a design is editable by more than one
99person, so a delete button is one misclick away from destroying a group project
100with only a server-admin restore behind it. Cleanup is an admin operation on the
101volume.
102"""
103
104# Deliberately NO `from __future__ import annotations`. The payload models are
105# per-app, so handlers are annotated `payload: store.body_model` -- an expression
106# that must evaluate to the class at definition time. Postponed evaluation would
107# turn it into the string "store.body_model", which FastAPI cannot resolve to a
108# type; it would silently fall back to treating the body as a query parameter and
109# every POST would 422.
110
111import fcntl
112import json
113import os
114import tempfile
115import time
116from contextlib import contextmanager
117from dataclasses import dataclass, field
118from datetime import datetime, timedelta, timezone
119from pathlib import Path
120from typing import Callable, Iterator
121
122from fastapi import APIRouter, HTTPException, Request
123from pydantic import BaseModel
124
125from stardesign import directory
126from stardesign.userdata import UserData, slug_user, slugify
127
128
129def _write_json_atomic(p: Path, data: object) -> None:
130 p.parent.mkdir(parents=True, exist_ok=True)
131 fd, tmp = tempfile.mkstemp(dir=p.parent, prefix=".tmp-", suffix=".json")
132 try:
133 with os.fdopen(fd, "w", encoding="utf-8") as fh:
134 json.dump(data, fh, indent=2)
135 os.replace(tmp, p)
136 except BaseException:
137 if os.path.exists(tmp):
138 os.unlink(tmp)
139 raise
140
141
142@dataclass
144 """One app's binding of the shared design machinery.
145
146 ``empty_payload`` and ``payload_of`` are what let one router serve both the
147 ``{"config": ...}`` apps and pid-designer's ``{"nodes": ..., "edges": ...}``:
148 everything else here treats a payload as an opaque JSON dict.
149 """
150
151 ud: UserData
152 backend: object #: stardesign.storage backend
153
154 #: Request bodies. Per-app, because the shape genuinely differs; everything
155 #: else in the router treats a payload as an opaque JSON dict.
156 body_model: type[BaseModel] #: autosave + flush
157 create_model: type[BaseModel] #: POST "" (name, and an optional seed)
158 release_model: type[BaseModel] #: POST /{id}/release
159 #: A body/create payload -> the dict to store.
160 to_data: Callable[[BaseModel], dict]
161 #: A release payload -> the dict to snapshot, or None to snapshot the
162 #: current working copy instead.
163 release_body: Callable[[BaseModel], dict | None]
164
165 noun: str = "design" #: user-facing singular, e.g. "diagram"
166 default_slug: str = "design" #: id stem when a name slugifies to nothing
167 micro_interval: int = 300 #: seconds between throttled snapshots
168 #: How long a checkout survives without a save. Expiry is evaluated lazily,
169 #: when someone tries to take the design -- no reaper, and exactly as correct
170 #: for the only question that matters ("can two people hold it at once?").
171 #:
172 #: 15 minutes, not 5: a save is what refreshes this, and reading a result or
173 #: thinking about one is not saving. At 5 minutes an ordinary pause cost you
174 #: the design. It stays finite because release is holder-only and the
175 #: on-close beacon is best-effort, so this is the only thing that frees a
176 #: checkout after a crash or a power cut.
177 lock_ttl: int = 900
178 #: An empty document body, used when creating and as a fallback on /load.
179 empty_payload: Callable[[], dict] = lambda: {"config": {}}
180 #: Every design editable by everyone, not just its creator and share list.
181 open_to_all: bool = False
182 #: With ``open_to_all``: other people's designs not updated in this many days
183 #: leave the editable list for ``/browse``. None keeps them all listed.
184 recent_days: int | None = None
185 #: Offer an admin-chosen main design that only admins may change.
186 featured: bool = False
187 #: With ``featured``: only admins may change the main design. Off, the main
188 #: design follows the same editor rules as any other.
189 main_admin_only: bool = True
190 #: The STAR collection and approved editors. Admins choose which designs are
191 #: the team's (the STAR set, which always includes the main one); everyone
192 #: sees those plus their own and any shared with them, admins see
193 #: everything. Editing is the creator, the share list and admins; anyone who
194 #: can see a design may ask to edit it, and its creator or an admin approves.
195 curated: bool = False
196 #: Whether the caller is an admin. Nobody, unless the app says otherwise.
197 is_admin: Callable[[Request], bool] = lambda request: False
198 #: (owner, doc_id) -> monotonic time of the last microversion. In-process
199 #: only; after a restart the worst case is one extra snapshot, which is
200 #: harmless. Keyed on the design's *owner*, not the editor: two people
201 #: editing one shared design must share a throttle clock, or it snapshots at
202 #: twice the intended rate.
203 last_micro: dict[tuple[str, str], float] = field(default_factory=dict)
204
205 # ── the index and the checkout, callable from outside the router ─────────
206 #
207 # These were closures inside make_router, which meant the checkout could
208 # only be enforced on the document routes. An app whose *own* routes edit
209 # the design -- EngineDesign writes the live config through /api/config --
210 # had no way to ask "does this caller hold it?", so the client was the only
211 # gate. They live here so any router can reuse the one implementation.
212
213 def index_path(self, user: str, *, create: bool = False) -> Path:
214 return self.ud.user_dir(user, create=create) / "index.json"
215
216 def load_index(self, user: str) -> list[dict]:
217 """One user's design records. Never creates their folder -- the
218 cross-user scan reads every sibling, and must not conjure a tree for
219 each one."""
220 p = self.index_path(user)
221 if not p.is_file():
222 return []
223 try:
224 data = json.loads(p.read_text("utf-8"))
225 return data if isinstance(data, list) else []
226 except (OSError, ValueError):
227 return []
228
229 def find_record(self, owner: str, doc_id: str) -> dict | None:
230 return next((r for r in self.load_index(owner) if r.get("id") == doc_id), None)
231
232 def featured_path(self) -> Path:
233 return self.ud.site_dir() / "featured.json"
234
235 def read_featured(self) -> dict | None:
236 """``{owner, id, setBy, setAt}`` for the main design, or None.
237
238 None as well when the mode is off, or the file names a design that no
239 longer exists -- a dangling pointer must not make anything read-only.
240 """
241 if not self.featured:
242 return None
243 try:
244 data = json.loads(self.featured_path().read_text("utf-8"))
245 except (OSError, ValueError, RuntimeError):
246 return None
247 if not isinstance(data, dict) or not data.get("owner") or not data.get("id"):
248 return None
249 owner, doc_id = str(data["owner"]), str(data["id"])
250 if self.find_record(owner, doc_id) is None:
251 return None
252 return {**data, "owner": owner, "id": doc_id}
253
254 def write_featured(self, data: dict | None) -> None:
255 """Set (or, with None, clear) the main design. Atomic, like the index."""
256 p = self.featured_path()
257 if data is None:
258 try:
259 p.unlink()
260 except FileNotFoundError:
261 pass
262 return
263 _write_json_atomic(p, data)
264
265 def star_path(self) -> Path:
266 return self.ud.site_dir() / "star.json"
267
268 @contextmanager
269 def star_lock(self) -> Iterator[None]:
270 """Serialise read-modify-write of the STAR set, as ``_index_lock`` does
271 for an index: two admins adding at once must not drop one."""
272 d = self.ud.site_dir(create=True)
273 fd = os.open(d / ".star.lock", os.O_CREAT | os.O_RDWR, 0o644)
274 try:
275 fcntl.flock(fd, fcntl.LOCK_EX)
276 yield
277 finally:
278 fcntl.flock(fd, fcntl.LOCK_UN)
279 os.close(fd)
280
281 def read_star_raw(self) -> list[dict]:
282 """The STAR set as stored, dangling entries included."""
283 if not self.curated:
284 return []
285 try:
286 data = json.loads(self.star_path().read_text("utf-8"))
287 except (OSError, ValueError, RuntimeError):
288 return []
289 if not isinstance(data, list):
290 return []
291 return [e for e in data if isinstance(e, dict) and e.get("owner") and e.get("id")]
292
293 def read_star(self) -> set[tuple[str, str]]:
294 """``(owner, id)`` of every STAR design, the main one included.
295
296 Entries naming a design that no longer exists are dropped, like a
297 dangling main pointer: they must not make anything visible.
298 """
299 if not self.curated:
300 return set()
301 out = {
302 (str(e["owner"]), str(e["id"]))
303 for e in self.read_star_raw()
304 if self.find_record(str(e["owner"]), str(e["id"])) is not None
305 }
306 main = self.read_featured()
307 if main is not None:
308 out.add((main["owner"], main["id"]))
309 return out
310
311 def write_star(self, entries: list[dict]) -> None:
312 _write_json_atomic(self.star_path(), entries)
313
314 def lock_holder(self, record: dict) -> str | None:
315 """Whoever currently holds the design, or None if it is free.
316
317 A checkout with no save inside ``lock_ttl`` is treated as free. Expiry is
318 decided here, on read, rather than by a background sweep -- which means
319 there is no window where a record says "held" and the answer to "may I
320 take it?" disagrees.
321 """
322 holder = record.get("lockedBy")
323 if not holder:
324 return None
325 beat = record.get("lockHeartbeat") or record.get("lockedAt")
326 if not beat:
327 return None
328 try:
329 age = (datetime.now(timezone.utc) - datetime.fromisoformat(beat)).total_seconds()
330 except (TypeError, ValueError):
331 return None # unparseable timestamp: treat as free rather than wedge the design
332 return str(holder) if age < self.lock_ttl else None
333
334 def require_lock_on(self, record: dict, doc_id: str, viewer: str) -> None:
335 """Refuse a content write unless ``viewer`` holds this design's checkout.
336
337 The 423 and its wording are the contract the design bar reads: it drops
338 to read-only on 423 rather than retrying into a void.
339 """
340 holder = self.lock_holder(record)
341 if holder != viewer:
342 name = record.get("name", doc_id)
343 detail = (
344 f"{name} is checked out by {holder}" if holder
345 else f"Take {name} before saving -- your checkout has lapsed"
346 )
347 raise HTTPException(status_code=423, detail=detail)
348
349 def save_index(self, user: str, index: list[dict]) -> None:
350 """Write one user's index atomically (temp file + rename)."""
351 p = self.index_path(user, create=True)
352 p.parent.mkdir(parents=True, exist_ok=True)
353 fd, tmp = tempfile.mkstemp(dir=p.parent, prefix=".tmp-", suffix=".json")
354 try:
355 with os.fdopen(fd, "w", encoding="utf-8") as fh:
356 json.dump(index, fh, indent=2)
357 os.replace(tmp, p)
358 except BaseException:
359 if os.path.exists(tmp):
360 os.unlink(tmp)
361 raise
362
363 @contextmanager
364 def index_lock(self, user: str) -> Iterator[None]:
365 """Serialize read-modify-write of one user's ``index.json``.
366
367 ``save_index`` is atomic per write, but read-modify-write is not, and the
368 index is edited by co-editors as well as its owner while the API runs
369 several workers. Without this, two overlapping updates drop a record. The
370 lock file lives beside the index and is never removed.
371 """
372 lock = self.ud.user_dir(user, create=True) / ".index.lock"
373 fd = os.open(lock, os.O_CREAT | os.O_RDWR, 0o644)
374 try:
375 fcntl.flock(fd, fcntl.LOCK_EX)
376 yield
377 finally:
378 fcntl.flock(fd, fcntl.LOCK_UN)
379 os.close(fd)
380
381 def claim_for_write(self, owner: str, doc_id: str, viewer: str) -> None:
382 """Let ``viewer`` write the design's content, or 423.
383
384 Passes when ``viewer`` holds the checkout. Also passes -- and takes the
385 hold back -- when the hold lapsed *on* ``viewer`` and nobody has taken
386 the design since: a lapsed record keeps ``lockedBy``, any take by
387 someone else overwrites it, and a release clears it, so ``lockedBy ==
388 viewer`` on a free design means nobody else can have written it.
389
390 Refusing that case was how work was lost. Step away past ``lock_ttl``,
391 come back and keep editing, and every save came back 423 with nobody
392 else anywhere near the design; the edits stayed on screen and nowhere
393 else. A lapse still frees the design for anyone who acts first -- this
394 only stops the holder from being locked out of a design nobody wanted.
395
396 The check and the reclaim happen under the index lock, so a reclaim and
397 someone else's take serialise and exactly one of them wins.
398 """
399 record = self.find_record(owner, doc_id)
400 if record is not None and self.lock_holder(record) == viewer:
401 return # the common case, without taking the index lock
402 with self.index_lock(owner):
403 index = self.load_index(owner)
404 for record in index:
405 if record.get("id") != doc_id:
406 continue
407 if self.lock_holder(record) is None and record.get("lockedBy") == viewer:
408 record["lockHeartbeat"] = datetime.now(timezone.utc).isoformat()
409 self.save_index(owner, index)
410 return
411 self.require_lock_on(record, doc_id, viewer)
412 return
413 raise HTTPException(status_code=404, detail=f"Unknown {self.noun}")
414
415
416# ── payloads that are the same in every app ──────────────────────────────────
417#
418# The autosave/create/release bodies differ per app (see DesignStore); these
419# three do not -- a name is a name, and sharing has one shape everywhere.
420
421
422class NamePayload(BaseModel):
423 name: str
424
425
426class CopyPayload(BaseModel):
427 owner: str # whose design to copy (email or slug)
428 id: str
429 name: str | None = None # default: "<name> (copy of <owner>)"
430
431
432class SharePayload(BaseModel):
433 sharedWith: list[str] # the whole list, not a delta
434
435
436class FeaturedPayload(BaseModel):
437 owner: str # whose folder the design lives in (email or slug)
438 id: str
439
440
441class RequesterPayload(BaseModel):
442 email: str # whose request to approve or deny, as the request recorded it
443
444
445def make_router(store: DesignStore, prefix: str, sub: str = "") -> APIRouter:
446 """The design CRUD + sharing router for one app.
447
448 ``prefix`` is the APIRouter prefix (e.g. ``/api/engine/documents``); ``sub``
449 is an extra path segment beneath it, which only pid-designer uses -- its
450 router is mounted at ``/api/pid`` and nests these routes under
451 ``/diagrams`` so ``/api/pid/users`` can sit alongside them.
452
453 Everything is defined in here as closures over ``store`` rather than as
454 module-level functions taking it: the alternative is threading one more
455 argument through twenty helpers, which is exactly the kind of repetition
456 that lets one of them get forgotten.
457 """
458 router = APIRouter(prefix=prefix, tags=[store.noun + "s"])
459
460 def _now_iso() -> str:
461 return datetime.now(timezone.utc).isoformat()
462
463
464 # ── working copy + document index (on the volume) ────────────────────────────
465
466 def _index_path(user: str, *, create: bool = False) -> Path:
467 return store.index_path(user, create=create)
468
469
470 def _load_index(user: str) -> list[dict]:
471 return store.load_index(user)
472
473
474 def _save_index(user: str, index: list[dict]) -> None:
475 store.save_index(user, index)
476
477
478 def _index_lock(user: str):
479 return store.index_lock(user)
480
481
482 def _require_id(doc_id: str) -> str:
483 """Sanitize a path-supplied id into a safe segment, or 404."""
484 safe = slugify(doc_id)
485 if not safe:
486 raise HTTPException(status_code=404, detail=f"Unknown {store.noun}")
487 return safe
488
489
490 def _unique_id(user: str, name: str) -> str:
491 base = slugify(name) or store.default_slug
492 existing = {d["id"] for d in _load_index(user)}
493 if base not in existing:
494 return base
495 n = 2
496 while f"{base}-{n}" in existing:
497 n += 1
498 return f"{base}-{n}"
499
500
501 def _working_path(user: str, doc_id: str) -> Path:
502 return store.ud.user_dir(user) / doc_id / "current.json"
503
504
505 def _read_working(user: str, doc_id: str) -> dict | None:
506 p = _working_path(user, doc_id)
507 if not p.is_file():
508 return None
509 try:
510 return json.loads(p.read_text("utf-8"))
511 except (OSError, ValueError):
512 return None
513
514
515 def _write_working(user: str, doc_id: str, data: dict) -> None:
516 p = _working_path(user, doc_id)
517 p.parent.mkdir(parents=True, exist_ok=True)
518 fd, tmp = tempfile.mkstemp(dir=p.parent, prefix=".tmp-", suffix=".json")
519 try:
520 with os.fdopen(fd, "w", encoding="utf-8") as fh:
521 json.dump(data, fh, indent=2)
522 os.replace(tmp, p)
523 except BaseException:
524 if os.path.exists(tmp):
525 os.unlink(tmp)
526 raise
527
528
529 def _write_base(user: str, doc_id: str, data: dict) -> None:
530 """The copy's common ancestor, beside its working copy. Written once."""
531 p = store.ud.user_dir(user) / doc_id / "base.json"
532 p.parent.mkdir(parents=True, exist_ok=True)
533 p.write_text(json.dumps(data, indent=2), "utf-8")
534
535 def _touch_index(user: str, doc_id: str) -> None:
536 _mutate_record(user, doc_id, updatedAt=_now_iso(), missing_ok=True)
537
538
539 def _mutate_record(user: str, doc_id: str, *, missing_ok: bool = False, **fields) -> dict:
540 """Apply ``fields`` to one index record, under the index lock.
541
542 Read-modify-write of a file two people may be editing, so it re-reads inside
543 the lock rather than trusting a record fetched earlier.
544 """
545 with _index_lock(user):
546 index = _load_index(user)
547 for record in index:
548 if record["id"] == doc_id:
549 record.update(fields)
550 _save_index(user, index)
551 return record
552 if missing_ok:
553 return {}
554 raise HTTPException(status_code=404, detail=f"Unknown {store.noun}")
555
556
557 def _edit_record(user: str, doc_id: str, fn: Callable[[dict], None]) -> dict:
558 """``_mutate_record`` for a change that depends on what the record holds
559 now -- a list to filter, say -- so ``fn`` runs on the copy read inside
560 the lock rather than one fetched before it."""
561 with _index_lock(user):
562 index = _load_index(user)
563 for record in index:
564 if record["id"] == doc_id:
565 fn(record)
566 _save_index(user, index)
567 return record
568 raise HTTPException(status_code=404, detail=f"Unknown {store.noun}")
569
570
571 # ── sharing: who may edit which design ───────────────────────────────────────
572
573 @dataclass(frozen=True)
574 class DocRef:
575 """A design the caller is allowed to act on.
576
577 ``owner`` is the folder the design lives in -- not a privilege level. It only
578 differs from ``viewer`` when someone is editing a design shared with them.
579 """
580
581 owner: str
582 doc_id: str
583 record: dict
584 viewer: str
585
586
587 def _shared_with(record: dict) -> list[str]:
588 """The record's editor list. Absent on every design created before sharing
589 existed, so a missing key is an empty list, never an error."""
590 value = record.get("sharedWith")
591 return [str(e) for e in value] if isinstance(value, list) else []
592
593
594 class Access:
595 """Who is asking, and the two facts about the stand-wide state that
596 decide what they may touch. Built once per request: ``admin`` may be a
597 network call, so it is asked at most once, and only if a decision
598 actually turns on it."""
599
600 def __init__(self, request: Request) -> None:
601 self.request = request
602 self.viewer = store.ud.current_user(request)
603 main = store.read_featured()
604 self.main = (main["owner"], main["id"]) if main else None
605 self.star = store.read_star()
606 self._admin: bool | None = None
607
608 @property
609 def admin(self) -> bool:
610 if self._admin is None:
611 self._admin = bool(store.is_admin(self.request))
612 return self._admin
613
614 def is_main(self, owner: str, doc_id: str) -> bool:
615 return self.main == (owner, doc_id)
616
617 def is_star(self, owner: str, doc_id: str) -> bool:
618 return (owner, doc_id) in self.star
619
620 def _is_shared(record: dict, viewer: str) -> bool:
621 return any(slug_user(e) == viewer for e in _shared_with(record))
622
623 def _can_edit(record: dict, owner: str, access: "Access") -> bool:
624 """Emails are stored as written, compared as path slugs -- the same
625 normalization ``current_user`` applies, so the two always line up.
626
627 An admin-only main design is checked first and overrides everything
628 else: not even its creator may change it without being an admin.
629 """
630 if store.main_admin_only and access.is_main(owner, record.get("id")):
631 return access.admin
632 if store.open_to_all or access.viewer == owner:
633 return True
634 if _is_shared(record, access.viewer):
635 return True
636 return store.curated and access.admin
637
638 def _can_manage(record: dict, owner: str, access: "Access") -> bool:
639 """Decide who else edits it: share, approve and deny. With ``curated``,
640 its creator or an admin. Otherwise any editor, which is the old rule."""
641 if store.curated:
642 return access.viewer == owner or access.admin
643 return _can_edit(record, owner, access)
644
645 def _can_view(record: dict, owner: str, access: "Access") -> bool:
646 """Open it in the editor, read-only if need be. The main design is
647 viewable by everyone; anything else, only by those who can edit it --
648 which with ``featured`` off is exactly the old rule. With ``curated``,
649 the STAR set is viewable by everyone and admins see everything."""
650 doc_id = record.get("id")
651 if access.is_main(owner, doc_id):
652 return True
653 if store.curated and (access.is_star(owner, doc_id) or access.admin):
654 return True
655 return _can_edit(record, owner, access)
656
657
658 def _find_record(owner: str, doc_id: str) -> dict | None:
659 return store.find_record(owner, doc_id)
660
661
662 def _resolve_doc(
663 request: Request,
664 owner: str | None,
665 doc_id: str,
666 *,
667 need: str = "edit",
668 ) -> DocRef:
669 """Resolve ``(?owner=, doc_id)`` into a design the caller may act on.
670
671 The single place cross-user access is granted. ``need="read"`` is for the
672 two endpoints that exist to look at other people's work (browse and copy);
673 ``"view"`` is for looking at a design in the editor (load, history,
674 releases, who holds it) -- the main design is viewable by all; and
675 everything else needs ``"edit"``, which means being the owner or on the
676 share list (or anyone, with ``open_to_all``), and an admin for the main
677 design. ``"exists"`` asks nothing (rescue only). ``"manage"`` -- deciding
678 who else edits it -- is any editor, or with ``curated`` its creator or
679 an admin.
680
681 Returning the resolved record -- rather than just a folder name -- is what
682 stops a handler that omits ``?owner=`` from silently writing an orphan
683 working copy into the caller's own tree. See the module docstring.
684 """
685 access = Access(request)
686 viewer = access.viewer
687 owner_slug = slug_user(owner) if owner else viewer
688 doc_id = _require_id(doc_id)
689 record = _find_record(owner_slug, doc_id)
690 if record is None:
691 raise HTTPException(status_code=404, detail=f"Unknown {store.noun}")
692 # "exists" is rescue's alone: it keeps the caller's own edits and reads
693 # only the source's name, so it must survive losing sight of it.
694 # Curated, a design you cannot see is not yours to read or copy either:
695 # "read" is only wider than "view" in the apps where everything is public.
696 if need == "read" and store.curated:
697 need = "view"
698 if need in ("view", "manage") and not _can_view(record, owner_slug, access):
699 raise HTTPException(status_code=403, detail="This design is not shared with you")
700 if need == "edit" and not _can_edit(record, owner_slug, access):
701 if store.main_admin_only and access.is_main(owner_slug, doc_id):
702 detail = f"Only an admin can change the main {store.noun}. Take a copy to work on it."
703 elif store.curated and _can_view(record, owner_slug, access):
704 detail = f"You need edit access to this {store.noun}. Ask for it, or take a copy."
705 else:
706 detail = "This design is not shared with you"
707 raise HTTPException(status_code=403, detail=detail)
708 if need == "manage" and not _can_manage(record, owner_slug, access):
709 raise HTTPException(
710 status_code=403,
711 detail=f"Only the {store.noun}'s creator or an admin can decide who edits it",
712 )
713 return DocRef(owner=owner_slug, doc_id=doc_id, record=record, viewer=viewer)
714
715
716 # ── checkouts: who may save, right now ───────────────────────────────────
717
718 def _lock_holder(record: dict) -> str | None:
719 return store.lock_holder(record)
720
721 def _lock_state(record: dict, viewer: str, names: dict[str, str]) -> dict:
722 """The checkout, as the design bar wants to render it.
723
724 ``lockExpiresAt`` is the moment the hold lapses -- beat + ``lock_ttl`` --
725 not the beat itself. It used to carry the raw heartbeat, which is a time
726 already in the past, so anything counting down to it read as expired the
727 instant it rendered. That is why the bar never showed the user how long
728 they had.
729 """
730 holder = _lock_holder(record)
731 expires = None
732 if holder:
733 beat = record.get("lockHeartbeat") or record.get("lockedAt")
734 if beat:
735 try:
736 expires = (
737 datetime.fromisoformat(beat) + timedelta(seconds=store.lock_ttl)
738 ).isoformat()
739 except (TypeError, ValueError):
740 expires = None
741 # A duration as well as an instant. The instant is only meaningful to a
742 # client whose clock agrees with ours, and nothing makes that true: a
743 # browser on one host talking to a server on another (Windows and WSL,
744 # say) can be hours apart, and the countdown then reads as hours left --
745 # or, when the browser runs fast, as permanently expired. The duration
746 # costs nothing and is immune to it, so it is what the bar counts down.
747 remaining = None
748 if expires is not None:
749 remaining = max(
750 0.0,
751 (datetime.fromisoformat(expires) - datetime.now(timezone.utc)).total_seconds(),
752 )
753 return {
754 "lockedBy": holder,
755 "lockedByName": (names.get(holder) or holder) if holder else None,
756 "lockedByMe": holder == viewer,
757 "lockExpiresAt": expires,
758 "lockExpiresInSeconds": remaining,
759 "lockTtlSeconds": store.lock_ttl,
760 }
761
762 def _take_lock(owner: str, doc_id: str, viewer: str) -> dict:
763 """Claim the design, or 423 if someone else has it.
764
765 The compare-and-set that makes concurrent editing impossible. Re-reads
766 the record *inside* the lock rather than trusting one fetched earlier,
767 so two racing takes serialise and exactly one sees a free design.
768 """
769 now = _now_iso()
770 with _index_lock(owner):
771 index = _load_index(owner)
772 for record in index:
773 if record["id"] != doc_id:
774 continue
775 holder = _lock_holder(record)
776 if holder and holder != viewer:
777 raise HTTPException(
778 status_code=423,
779 detail=f"{record.get('name', doc_id)} is checked out by {holder}",
780 )
781 record["lockedBy"] = viewer
782 record.setdefault("lockedAt", now)
783 if holder != viewer:
784 record["lockedAt"] = now
785 record["lockHeartbeat"] = now
786 _save_index(owner, index)
787 return record
788 raise HTTPException(status_code=404, detail=f"Unknown {store.noun}")
789
790 def _release_lock(owner: str, doc_id: str, viewer: str) -> None:
791 """Give the design back. Only the holder can, and it is idempotent --
792 a lock that already lapsed, or was never held, is simply already free."""
793 with _index_lock(owner):
794 index = _load_index(owner)
795 for record in index:
796 if record["id"] != doc_id:
797 continue
798 if _lock_holder(record) not in (None, viewer):
799 return # someone else's now; not ours to drop
800 record.pop("lockedBy", None)
801 record.pop("lockedAt", None)
802 record.pop("lockHeartbeat", None)
803 _save_index(owner, index)
804 return
805
806 def _require_lock(ref: "DocRef") -> None:
807 """Refuse a content write unless the caller holds the checkout -- or
808 held it, let it lapse, and nobody has taken it since (see
809 ``DesignStore.claim_for_write``).
810
811 Applied to the endpoints that mutate the design itself. Renaming,
812 sharing and copying are deliberately exempt: they are not concurrent
813 editing of content, and blocking them would mean a checkout could stop
814 someone tidying up a design they can see.
815 """
816 store.claim_for_write(ref.owner, ref.doc_id, ref.viewer)
817
818 def _beat_lock(owner: str, doc_id: str, viewer: str) -> None:
819 """Refresh the holder's checkout after a successful save.
820
821 This is what makes "inactivity" mean *not editing* rather than *tab
822 closed*, and it costs no extra request.
823 """
824 with _index_lock(owner):
825 index = _load_index(owner)
826 for record in index:
827 if record["id"] == doc_id and record.get("lockedBy") == viewer:
828 record["lockHeartbeat"] = _now_iso()
829 _save_index(owner, index)
830 return
831
832 def _scan_all() -> Iterator[tuple[str, dict]]:
833 """``(owner, record)`` for every design on the shared volume.
834
835 All the design tools mount one volume keyed by email, which is what makes
836 cross-user listing a directory walk instead of a database. At team scale
837 this is a few dozen small JSON reads.
838 """
839 for owner in store.ud.all_users():
840 for record in _load_index(owner):
841 if record.get("id"):
842 yield owner, record
843
844
845 def _decorated_for(request: Request, ref: "DocRef", record: dict) -> dict:
846 """One record, shaped exactly like the ones `list` returns.
847
848 Every endpoint that hands a record back has to go through this. Returning
849 the bare stored record instead drops `owner`/`ownerName`/`mine`, and the
850 design bar reads a missing `mine` as "someone else's" -- which showed up
851 as a design of your own captioned "Config 1 - undefined".
852 """
853 names = directory.display_names(request, store.ud)
854 return _decorate(record, ref.owner, Access(request), names)
855
856 def _decorate(record: dict, owner: str, access: "Access", names: dict[str, str]) -> dict:
857 """An index record as the UI wants it: who owns it, is that me, and may
858 I change it."""
859 viewer = access.viewer
860 extra: dict = {}
861 if store.curated:
862 requests = _requests_of(record)
863 manage = _can_manage(record, owner, access)
864 extra = {
865 "star": access.is_star(owner, record.get("id")),
866 "canManage": manage,
867 # Who is asking is the business of whoever can answer; anyone
868 # else only learns whether they themselves have asked.
869 "accessRequests": [
870 {**r, "name": names.get(r["email"]) or r["email"]} for r in requests
871 ] if manage else [],
872 "requestedByMe": any(r["email"] == viewer for r in requests),
873 # An admin may edit everything, so `editable` cannot tell a
874 # design shared with them from anyone else's; the Mine tab can.
875 "sharedWithMe": owner != viewer and _is_shared(record, viewer),
876 }
877 return {
878 **record,
879 "sharedWith": _shared_with(record),
880 "owner": owner,
881 "ownerName": names.get(owner) or owner,
882 "mine": owner == viewer,
883 "editable": _can_edit(record, owner, access),
884 "featured": access.is_main(owner, record.get("id")),
885 **extra,
886 # Folded in here so the design bar knows the checkout state from the
887 # list it already fetches, with no extra round trip on open.
888 **_lock_state(record, viewer, names),
889 }
890
891 def _requests_of(record: dict) -> list[dict]:
892 """Pending edit-access requests, ``[{email, at}]``. Absent means none."""
893 value = record.get("accessRequests")
894 if not isinstance(value, list):
895 return []
896 return [
897 {"email": slug_user(str(r["email"])), "at": r.get("at")}
898 for r in value if isinstance(r, dict) and r.get("email")
899 ]
900
901
902 # ── document CRUD ─────────────────────────────────────────────────────────────
903
904 def _listed(record: dict, owner: str, access: "Access", cutoff: str | None) -> bool:
905 """Whether a design belongs in the caller's list (else in ``/browse``).
906
907 The main design always does. Otherwise it is the ones you can edit, and
908 with ``recent_days`` set, other people's drop out once they go quiet --
909 which only moves them to browse, where they still open. Your own, and
910 ones explicitly shared with you, are never aged out.
911
912 With ``curated`` it is simply everything you can see: the STAR set,
913 your own and shared ones, and for an admin, everything.
914 """
915 if access.is_main(owner, record.get("id")):
916 return True
917 if store.curated:
918 return _can_view(record, owner, access)
919 if not _can_edit(record, owner, access):
920 return False
921 if cutoff is None or owner == access.viewer:
922 return True
923 if any(slug_user(e) == access.viewer for e in _shared_with(record)):
924 return True
925 return (record.get("updatedAt") or "") >= cutoff
926
927 def _cutoff() -> str | None:
928 if not store.open_to_all or store.recent_days is None:
929 return None
930 return (datetime.now(timezone.utc) - timedelta(days=store.recent_days)).isoformat()
931
932 @router.get(sub)
933 async def list_documents(request: Request):
934 """Designs the caller may edit -- their own plus any shared with them,
935 or with ``open_to_all`` everyone's recent ones -- and the main design.
936 With ``curated``, everything the caller may see, STAR designs first."""
937 access = Access(request)
938 names = directory.display_names(request, store.ud)
939 cutoff = _cutoff()
940 out = [
941 _decorate(record, owner, access, names)
942 for owner, record in _scan_all()
943 if _listed(record, owner, access, cutoff)
944 ]
945 out.sort(key=lambda d: d.get("updatedAt") or "", reverse=True)
946 if store.curated:
947 out.sort(key=lambda d: not d["star"])
948 # The main design heads the list, whatever its age.
949 out.sort(key=lambda d: not d["featured"])
950 return out
951
952
953 @router.get(f"{sub}/browse")
954 async def browse_documents(request: Request):
955 """Everyone else's designs, grouped by owner -- the view-only tree.
956
957 Exactly what the list leaves out: offering to copy something you can just
958 open is noise. Anything listed here can be copied by anyone; that is the
959 point. With ``open_to_all`` it is also where older designs live, and
960 ``editable`` says they open rather than only copy.
961 """
962 access = Access(request)
963 names = directory.display_names(request, store.ud)
964 cutoff = _cutoff()
965 groups: dict[str, list[dict]] = {}
966 for owner, record in _scan_all():
967 if _listed(record, owner, access, cutoff):
968 continue
969 # Curated, the list already holds all you may see; nothing is left.
970 if store.curated and not _can_view(record, owner, access):
971 continue
972 groups.setdefault(owner, []).append({
973 "id": record["id"],
974 "name": record.get("name", record["id"]),
975 "updatedAt": record.get("updatedAt"),
976 "editable": _can_edit(record, owner, access),
977 })
978 return [
979 {
980 "owner": owner,
981 "ownerName": names.get(owner) or owner,
982 "designs": sorted(
983 designs, key=lambda d: d.get("updatedAt") or "", reverse=True
984 ),
985 }
986 for owner, designs in sorted(groups.items())
987 ]
988
989
990 def _create(user: str, name: str, data: dict, extra: dict | None = None) -> dict:
991 """Add a design to ``user``'s index and seed its working copy.
992
993 The creator gets the checkout. Nobody else can hold a design that did not
994 exist a moment ago, and the alternative is worse than it sounds: you make
995 a design, start typing, and every edit is refused until you notice a
996 button you had no reason to look at.
997 """
998 now = _now_iso()
999 with _index_lock(user):
1000 doc_id = _unique_id(user, name)
1001 meta = {
1002 "id": doc_id,
1003 "name": name,
1004 "createdAt": now,
1005 "updatedAt": now,
1006 "sharedWith": [],
1007 "lockedBy": user,
1008 "lockedAt": now,
1009 "lockHeartbeat": now,
1010 # Reserved: with delete gone, nothing shrinks a list, and every
1011 # design is visible to the whole team through the view-only tree.
1012 # Archiving is the destroy-nothing replacement, and writing the
1013 # field now saves a second pass over everyone's index later.
1014 "archived": False,
1015 **(extra or {}),
1016 }
1017 index = _load_index(user)
1018 index.append(meta)
1019 _save_index(user, index)
1020 _write_working(user, doc_id, data)
1021 return meta
1022
1023
1024 @router.post(sub)
1025 async def create_document(request: Request, payload: store.create_model):
1026 """Create a new design owned by the caller, seeded with an optional config."""
1027 user = store.ud.current_user(request)
1028 meta = _create(user, payload.name.strip() or "Untitled", store.to_data(payload))
1029 names = directory.display_names(request, store.ud)
1030 return _decorate(meta, user, Access(request), names)
1031
1032
1033 @router.post(f"{sub}/copy")
1034 async def copy_document(request: Request, payload: CopyPayload):
1035 """Copy any design -- yours or anyone else's -- into the caller's own list.
1036
1037 Deliberately not gated on sharing: every design is viewable and copyable by
1038 anyone, which is what the view-only tree offers. What a copy does *not*
1039 inherit is history, releases, or the share list. It is a new design that
1040 happens to start from someone else's state, so editing it can never affect
1041 the original.
1042 """
1043 viewer = store.ud.current_user(request)
1044 ref = _resolve_doc(request, payload.owner, payload.id, need="read")
1045 data = _read_working(ref.owner, ref.doc_id)
1046 if data is None:
1047 data = store.backend.latest_micro(ref.owner, ref.doc_id) or store.empty_payload()
1048 names = directory.display_names(request, store.ud)
1049 who = names.get(ref.owner) or ref.owner
1050 source = ref.record.get("name", ref.doc_id)
1051 name = (payload.name or "").strip() or f"{source} (copy of {who})"
1052 # With ``featured`` on, a copy remembers where it came from and what that
1053 # looked like at the moment it was taken. That snapshot is the common
1054 # ancestor a later merge back into the main design needs; without it a
1055 # merge cannot tell "I deleted this" from "they added it".
1056 extra = None
1057 if store.featured:
1058 extra = {"copiedFrom": {"owner": ref.owner, "id": ref.doc_id, "name": source,
1059 "at": _now_iso()}}
1060 meta = _create(viewer, name, data, extra)
1061 if store.featured:
1062 _write_base(viewer, meta["id"], data)
1063 return _decorate(meta, viewer, Access(request), names)
1064
1065 @router.post(f"{sub}/{{doc_id}}/rescue")
1066 async def rescue_document(
1067 request: Request, doc_id: str, payload: store.body_model, owner: str | None = None
1068 ):
1069 """Keep edits the design refused, as a new design of the caller's own.
1070
1071 A save comes back 423 when the checkout has gone to someone else. The
1072 edits that save carried exist nowhere but the caller's screen, and the
1073 two things they do next -- "Take it back", which reloads the shared
1074 copy, or closing the tab -- both throw them away. The client posts them
1075 here first. Nothing is merged into the shared design (that would be the
1076 overwrite checkouts prevent); the caller gets a design with exactly what
1077 they had, named after the one it came from, checked out to them.
1078
1079 Read access is enough: someone unshared mid-edit lost write access too,
1080 and their work is still theirs. Curated, unshared can also mean they no
1081 longer see it, so this asks only that the design exists: it reads
1082 nothing from it but its name, which the caller had open a moment ago.
1083 """
1084 viewer = store.ud.current_user(request)
1085 ref = _resolve_doc(request, owner, doc_id, need="exists")
1086 source = ref.record.get("name", ref.doc_id)
1087 stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
1088 name = f"{source} (unsaved changes, {stamp})"
1089 names = directory.display_names(request, store.ud)
1090 return _decorate(
1091 _create(viewer, name, store.to_data(payload)), viewer, Access(request), names
1092 )
1093
1094
1095 @router.patch(f"{sub}/{{doc_id}}")
1096 async def rename_document(
1097 request: Request, doc_id: str, payload: NamePayload, owner: str | None = None
1098 ):
1099 """Rename a design. The id (and all storage keys) stay fixed."""
1100 ref = _resolve_doc(request, owner, doc_id)
1101 name = payload.name.strip()
1102 if not name:
1103 raise HTTPException(status_code=400, detail="Name is required")
1104 return _decorated_for(
1105 request, ref, _mutate_record(ref.owner, ref.doc_id, name=name, updatedAt=_now_iso())
1106 )
1107
1108
1109 # ── sharing ──────────────────────────────────────────────────────────────────
1110
1111 @router.put(f"{sub}/{{doc_id}}/share")
1112 async def share_document(
1113 request: Request, doc_id: str, payload: SharePayload, owner: str | None = None
1114 ):
1115 """Replace the design's editor list.
1116
1117 Takes the whole list rather than an add/remove delta, so two editors
1118 changing shares at the same time converge on one of their two lists instead
1119 of interleaving into a third nobody chose.
1120
1121 Any editor may change it, including removing others. There is no
1122 owner/editor distinction to appeal to -- the "owner" is just whoever clicked
1123 New -- and a removed editor can still read and copy the design anyway, so
1124 this is housekeeping, not a security boundary. ``sharedUpdatedBy`` records
1125 who last touched it.
1126
1127 With ``curated`` it is a boundary -- the list is who may edit -- so only
1128 the creator or an admin may change it, and sharing with someone who had
1129 asked answers their request.
1130 """
1131 ref = _resolve_doc(request, owner, doc_id, need="manage")
1132 seen: set[str] = set()
1133 emails: list[str] = []
1134 for raw in payload.sharedWith:
1135 email = str(raw).strip().lower()
1136 slug = slug_user(email) if email else ""
1137 # The owner is never on their own share list; that asymmetry is what
1138 # makes them the one person who cannot be removed from a design.
1139 if not email or slug == ref.owner or slug in seen:
1140 continue
1141 seen.add(slug)
1142 emails.append(email)
1143
1144 def apply(record: dict) -> None:
1145 record.update(sharedWith=emails, sharedUpdatedBy=ref.viewer,
1146 sharedUpdatedAt=_now_iso())
1147 if "accessRequests" in record:
1148 record["accessRequests"] = [
1149 r for r in _requests_of(record) if r["email"] not in seen
1150 ]
1151
1152 return _decorated_for(request, ref, _edit_record(ref.owner, ref.doc_id, apply))
1153
1154
1155 @router.delete(f"{sub}/{{doc_id}}/share/me")
1156 async def leave_document(request: Request, doc_id: str, owner: str | None = None):
1157 """Remove yourself from a design someone shared with you.
1158
1159 With delete gone this is the only thing that shrinks your list, and it
1160 destroys nothing: the design stays exactly where it is and you can copy it
1161 back out of the view-only tree whenever you like. The owner cannot leave --
1162 the design would end up in nobody's editable list.
1163 """
1164 ref = _resolve_doc(request, owner, doc_id)
1165 if ref.owner == ref.viewer:
1166 raise HTTPException(
1167 status_code=400,
1168 detail="You own this design, so you cannot leave it.",
1169 )
1170 remaining = [
1171 e for e in _shared_with(ref.record) if slug_user(e) != ref.viewer
1172 ]
1173 _mutate_record(
1174 ref.owner,
1175 ref.doc_id,
1176 sharedWith=remaining,
1177 sharedUpdatedBy=ref.viewer,
1178 sharedUpdatedAt=_now_iso(),
1179 )
1180 return {"ok": True}
1181
1182
1183 # ── checkouts ────────────────────────────────────────────────────────────
1184
1185 @router.post(f"{sub}/{{doc_id}}/checkout")
1186 async def take_checkout(request: Request, doc_id: str, owner: str | None = None):
1187 """Take the design's write token, or 423 if someone else has it.
1188
1189 423 rather than 409 on purpose: ``create_release`` already returns 409
1190 for "that label exists", and the two are surfaced differently in the UI.
1191
1192 Note this is *not* called on open -- viewing a design must never block a
1193 colleague. The client calls it when the user presses Take.
1194 """
1195 ref = _resolve_doc(request, owner, doc_id)
1196 record = _take_lock(ref.owner, ref.doc_id, ref.viewer)
1197 names = directory.display_names(request, store.ud)
1198 return _lock_state(record, ref.viewer, names)
1199
1200 @router.delete(f"{sub}/{{doc_id}}/checkout")
1201 async def release_checkout(request: Request, doc_id: str, owner: str | None = None):
1202 """Give the token back. Idempotent, and the target of the on-close beacon
1203 -- which is best-effort, so ``lock_ttl`` remains the real backstop."""
1204 ref = _resolve_doc(request, owner, doc_id)
1205 _release_lock(ref.owner, ref.doc_id, ref.viewer)
1206 return {"ok": True}
1207
1208 @router.post(f"{sub}/{{doc_id}}/checkout/release")
1209 async def release_checkout_beacon(
1210 request: Request, doc_id: str, owner: str | None = None
1211 ):
1212 """Release, reachable by ``navigator.sendBeacon`` on tab close.
1213
1214 A separate route only because a beacon can only ever POST -- it cannot
1215 issue the DELETE above. Same effect, and equally idempotent. Nothing can
1216 read a beacon's response, so this is best-effort: the inactivity timeout
1217 is what actually guarantees an abandoned checkout is freed.
1218 """
1219 ref = _resolve_doc(request, owner, doc_id)
1220 _release_lock(ref.owner, ref.doc_id, ref.viewer)
1221 return {"ok": True}
1222
1223 @router.post(f"{sub}/{{doc_id}}/checkout/beat")
1224 async def beat_checkout(request: Request, doc_id: str, owner: str | None = None):
1225 """Refresh your own hold without writing anything.
1226
1227 Until this existed the only thing that refreshed a checkout was a
1228 successful ``/autosave``, so "inactivity" meant "no change to the saved
1229 payload" rather than "not working". Reading a result, panning a canvas,
1230 measuring something or thinking about it all counted as idle, and the
1231 design was taken out from under you mid-task. The client sends this while
1232 you are actually interacting; it is deliberately content-free so it can
1233 never race an autosave or churn a microversion.
1234
1235 423 if you do not hold it -- the client treats that as "you lost it" and
1236 stops, rather than beating into a void.
1237 """
1238 ref = _resolve_doc(request, owner, doc_id)
1239 _require_lock(ref)
1240 _beat_lock(ref.owner, ref.doc_id, ref.viewer)
1241 record = store.find_record(ref.owner, ref.doc_id) or ref.record
1242 names = directory.display_names(request, store.ud)
1243 return _lock_state(record, ref.viewer, names)
1244
1245 @router.get(f"{sub}/{{doc_id}}/checkout")
1246 async def get_checkout(request: Request, doc_id: str, owner: str | None = None):
1247 """Who holds it right now. Polled by the bar while you do not, so that
1248 Take lights up on its own when the holder finishes."""
1249 ref = _resolve_doc(request, owner, doc_id, need="view")
1250 names = directory.display_names(request, store.ud)
1251 return _lock_state(ref.record, ref.viewer, names)
1252
1253 # ── working copy: load + autosave ────────────────────────────────────────────
1254
1255 @router.get(f"{sub}/{{doc_id}}/load")
1256 async def load_document(request: Request, doc_id: str, owner: str | None = None):
1257 """Load the freshest state: the working copy.
1258
1259 Falls back to the latest microversion only if the working copy is missing (a
1260 fresh or lost volume) -- self-healing. Never silently returns an older
1261 snapshot otherwise; that is what /history is for.
1262 """
1263 ref = _resolve_doc(request, owner, doc_id, need="view")
1264 data = _read_working(ref.owner, ref.doc_id)
1265 if data is None:
1266 data = store.backend.latest_micro(ref.owner, ref.doc_id)
1267 if data is not None:
1268 _write_working(ref.owner, ref.doc_id, data) # rehydrate the volume
1269 return data or store.empty_payload()
1270
1271
1272 @router.post(f"{sub}/{{doc_id}}/autosave")
1273 async def autosave_document(
1274 request: Request, doc_id: str, payload: store.body_model, owner: str | None = None
1275 ):
1276 """Write the working copy; snapshot a microversion only once per store.micro_interval.
1277
1278 A design shared with several people is last-write-wins here, on purpose --
1279 checkouts are the next piece of work. A client that is autosaving a design
1280 it has just been unshared from gets a 403, which is its cue to stop.
1281 """
1282 ref = _resolve_doc(request, owner, doc_id)
1283 _require_lock(ref)
1284 data = store.to_data(payload)
1285 _write_working(ref.owner, ref.doc_id, data)
1286 _beat_lock(ref.owner, ref.doc_id, ref.viewer)
1287
1288 micro = False
1289 key = (ref.owner, ref.doc_id)
1290 now = time.monotonic()
1291 if now - store.last_micro.get(key, 0.0) >= store.micro_interval:
1292 try:
1293 store.backend.snapshot_micro(ref.owner, ref.doc_id, data)
1294 store.last_micro[key] = now
1295 _touch_index(ref.owner, ref.doc_id)
1296 micro = True
1297 except Exception:
1298 # Never let a snapshot failure lose the edit -- the working copy is
1299 # already saved. Retry on the next autosave.
1300 pass
1301 return {"ok": True, "micro": micro}
1302
1303
1304 @router.post(f"{sub}/{{doc_id}}/flush")
1305 async def flush_document(
1306 request: Request, doc_id: str, payload: store.body_model, owner: str | None = None
1307 ):
1308 """Force an immediate microversion. Target of the on-close sendBeacon.
1309
1310 The body is an *override*, not the source of truth. It is honoured only
1311 when it actually carries a document; an empty one falls back to the
1312 working copy already on disk.
1313
1314 That asymmetry is the whole point. This endpoint is the target of a
1315 page-unload `sendBeacon`, which is the single request in the system most
1316 likely to arrive truncated, retried, or synthesised by something that is
1317 not the editor -- and the body model defaults every field to empty, so a
1318 body of `{}` validates perfectly. Before this, `POST /flush {}` replaced
1319 the working copy with nothing, snapshotted the nothing into history, and
1320 returned `{"ok": true}`. A diagram could be destroyed by a request that
1321 looked, from the client side, like a successful save.
1322
1323 Emptying a design deliberately still works: that is `autosave`, which is
1324 a foreground request the editor makes on purpose after a Clear.
1325 """
1326 ref = _resolve_doc(request, owner, doc_id)
1327 _require_lock(ref)
1328 data = store.to_data(payload)
1329 if data == store.empty_payload():
1330 data = _read_working(ref.owner, ref.doc_id) or data
1331 _write_working(ref.owner, ref.doc_id, data)
1332 try:
1333 store.backend.snapshot_micro(ref.owner, ref.doc_id, data)
1334 store.last_micro[(ref.owner, ref.doc_id)] = time.monotonic()
1335 _touch_index(ref.owner, ref.doc_id)
1336 except Exception as e:
1337 raise HTTPException(status_code=502, detail=f"Snapshot failed: {e}")
1338 return {"ok": True}
1339
1340
1341 # ── microversion history ─────────────────────────────────────────────────────
1342
1343 @router.get(f"{sub}/{{doc_id}}/history")
1344 async def get_history(request: Request, doc_id: str, owner: str | None = None):
1345 """Recent automatic microversions, newest first."""
1346 ref = _resolve_doc(request, owner, doc_id, need="view")
1347 return store.backend.list_micro(ref.owner, ref.doc_id)
1348
1349
1350 @router.get(f"{sub}/{{doc_id}}/version/{{version_id}}")
1351 async def get_version(
1352 request: Request, doc_id: str, version_id: str, owner: str | None = None
1353 ):
1354 """Fetch one microversion (for preview/restore). Does not touch the working copy."""
1355 ref = _resolve_doc(request, owner, doc_id, need="view")
1356 data = store.backend.get_micro(ref.owner, ref.doc_id, version_id)
1357 if data is None:
1358 raise HTTPException(status_code=404, detail="Version not found")
1359 return data
1360
1361
1362 # ── releases ─────────────────────────────────────────────────────────────────
1363
1364 @router.post(f"{sub}/{{doc_id}}/release")
1365 async def create_release(
1366 request: Request, doc_id: str, payload: store.release_model, owner: str | None = None
1367 ):
1368 """Publish the current state as an immutable named release (e.g. "0.1")."""
1369 ref = _resolve_doc(request, owner, doc_id)
1370 _require_lock(ref)
1371 label = slugify(payload.label)
1372 if not label:
1373 raise HTTPException(status_code=400, detail="A version label is required")
1374
1375 # A release either carries a body to snapshot, or says "snapshot what is
1376 # already saved". Which is which is app-specific -- the payload shapes
1377 # differ -- so the store decides.
1378 supplied = store.release_body(payload)
1379 if supplied is not None:
1380 data = supplied
1381 _write_working(ref.owner, ref.doc_id, data)
1382 else:
1383 data = _read_working(ref.owner, ref.doc_id) or store.empty_payload()
1384
1385 try:
1386 meta = store.backend.create_release(ref.owner, ref.doc_id, label, data)
1387 except FileExistsError:
1388 raise HTTPException(status_code=409, detail=f"Release '{label}' already exists")
1389 _touch_index(ref.owner, ref.doc_id)
1390 return meta
1391
1392
1393 @router.get(f"{sub}/{{doc_id}}/releases")
1394 async def list_releases(request: Request, doc_id: str, owner: str | None = None):
1395 """Published releases, newest first."""
1396 ref = _resolve_doc(request, owner, doc_id, need="view")
1397 return store.backend.list_releases(ref.owner, ref.doc_id)
1398
1399
1400 @router.get(f"{sub}/{{doc_id}}/release/{{label}}")
1401 async def get_release(
1402 request: Request, doc_id: str, label: str, owner: str | None = None
1403 ):
1404 """Fetch one release snapshot (for preview/restore)."""
1405 ref = _resolve_doc(request, owner, doc_id, need="view")
1406 data = store.backend.get_release(ref.owner, ref.doc_id, slugify(label))
1407 if data is None:
1408 raise HTTPException(status_code=404, detail="Release not found")
1409 return data
1410
1411 # ── the STAR set (curated) ───────────────────────────────────────────
1412
1413 def _add_star(owner: str, doc_id: str, viewer: str) -> None:
1414 with store.star_lock():
1415 entries = store.read_star_raw()
1416 if not any((e["owner"], e["id"]) == (owner, doc_id) for e in entries):
1417 entries.append({"owner": owner, "id": doc_id,
1418 "addedBy": viewer, "addedAt": _now_iso()})
1419 store.write_star(entries)
1420
1421 def _remove_star(owner: str, doc_id: str) -> None:
1422 with store.star_lock():
1423 entries = store.read_star_raw()
1424 kept = [e for e in entries if (e["owner"], e["id"]) != (owner, doc_id)]
1425 if len(kept) != len(entries):
1426 store.write_star(kept)
1427
1428 if store.curated:
1429
1430 def _require_star_admin(request: Request) -> str:
1431 access = Access(request)
1432 if not access.admin:
1433 raise HTTPException(
1434 status_code=403,
1435 detail=f"Only an admin can choose the STAR {store.noun}s",
1436 )
1437 return access.viewer
1438
1439 @router.put(f"{sub}/{{doc_id}}/star")
1440 async def star_document(request: Request, doc_id: str, owner: str | None = None):
1441 """Add a design to the STAR set, which everyone sees. Admins only."""
1442 viewer = _require_star_admin(request)
1443 ref = _resolve_doc(request, owner, doc_id, need="view")
1444 _add_star(ref.owner, ref.doc_id, viewer)
1445 return _decorated_for(request, ref, ref.record)
1446
1447 @router.delete(f"{sub}/{{doc_id}}/star")
1448 async def unstar_document(request: Request, doc_id: str, owner: str | None = None):
1449 """Take a design out of the STAR set. Admins only.
1450
1451 The main design is always a STAR one, so taking it out also stops it
1452 being main -- otherwise the pointer would keep it in the set and the
1453 button would appear to do nothing.
1454 """
1455 _require_star_admin(request)
1456 ref = _resolve_doc(request, owner, doc_id, need="view")
1457 _remove_star(ref.owner, ref.doc_id)
1458 main = store.read_featured()
1459 if main is not None and (main["owner"], main["id"]) == (ref.owner, ref.doc_id):
1460 store.write_featured(None)
1461 return _decorated_for(request, ref, ref.record)
1462
1463 # ── asking to edit, and answering ────────────────────────────────
1464
1465 @router.post(f"{sub}/{{doc_id}}/access")
1466 async def request_access(request: Request, doc_id: str, owner: str | None = None):
1467 """Ask to edit a design you can see. Its creator or an admin answers.
1468
1469 Idempotent: asking twice is one request, with the first time kept.
1470 """
1471 ref = _resolve_doc(request, owner, doc_id, need="view")
1472 if _can_edit(ref.record, ref.owner, Access(request)):
1473 raise HTTPException(status_code=400, detail=f"You can already edit this {store.noun}")
1474
1475 def apply(record: dict) -> None:
1476 requests = _requests_of(record)
1477 if not any(r["email"] == ref.viewer for r in requests):
1478 requests.append({"email": ref.viewer, "at": _now_iso()})
1479 record["accessRequests"] = requests
1480
1481 return _decorated_for(request, ref, _edit_record(ref.owner, ref.doc_id, apply))
1482
1483 @router.delete(f"{sub}/{{doc_id}}/access")
1484 async def withdraw_access_request(
1485 request: Request, doc_id: str, owner: str | None = None
1486 ):
1487 """Take back your own request. Idempotent."""
1488 ref = _resolve_doc(request, owner, doc_id, need="view")
1489
1490 def apply(record: dict) -> None:
1491 record["accessRequests"] = [
1492 r for r in _requests_of(record) if r["email"] != ref.viewer
1493 ]
1494
1495 return _decorated_for(request, ref, _edit_record(ref.owner, ref.doc_id, apply))
1496
1497 def _answer(request: Request, doc_id: str, owner: str | None, email: str,
1498 approve: bool) -> dict:
1499 ref = _resolve_doc(request, owner, doc_id, need="manage")
1500 who = slug_user(email.strip())
1501
1502 def apply(record: dict) -> None:
1503 requests = _requests_of(record)
1504 if not any(r["email"] == who for r in requests):
1505 raise HTTPException(status_code=404, detail=f"{who} has not asked to edit this")
1506 record["accessRequests"] = [r for r in requests if r["email"] != who]
1507 if approve:
1508 shared = _shared_with(record)
1509 if not _is_shared(record, who) and who != ref.owner:
1510 shared.append(who)
1511 record.update(sharedWith=shared, sharedUpdatedBy=ref.viewer,
1512 sharedUpdatedAt=_now_iso())
1513
1514 return _decorated_for(request, ref, _edit_record(ref.owner, ref.doc_id, apply))
1515
1516 @router.post(f"{sub}/{{doc_id}}/access/approve")
1517 async def approve_access(
1518 request: Request, doc_id: str, payload: RequesterPayload, owner: str | None = None
1519 ):
1520 """Let someone who asked edit it: they join the share list."""
1521 return _answer(request, doc_id, owner, payload.email, approve=True)
1522
1523 @router.post(f"{sub}/{{doc_id}}/access/deny")
1524 async def deny_access(
1525 request: Request, doc_id: str, payload: RequesterPayload, owner: str | None = None
1526 ):
1527 """Turn a request down. They keep what they had -- seeing it, and
1528 copying it -- and may ask again."""
1529 return _answer(request, doc_id, owner, payload.email, approve=False)
1530
1531 @router.get(f"{sub}/requests")
1532 async def list_access_requests(request: Request):
1533 """Every pending request the caller may answer: on their own designs,
1534 and for an admin on everyone's. What the badge on Change counts."""
1535 access = Access(request)
1536 names = directory.display_names(request, store.ud)
1537 out = []
1538 for owner, record in _scan_all():
1539 requests = _requests_of(record)
1540 if not requests or not _can_manage(record, owner, access):
1541 continue
1542 for r in requests:
1543 out.append({
1544 "owner": owner,
1545 "ownerName": names.get(owner) or owner,
1546 "id": record["id"],
1547 "name": record.get("name", record["id"]),
1548 "email": r["email"],
1549 "requesterName": names.get(r["email"]) or r["email"],
1550 "at": r["at"],
1551 })
1552 out.sort(key=lambda r: r["at"] or "")
1553 return out
1554
1555 # ── the main design ──────────────────────────────────────────────────
1556
1557 if store.featured:
1558
1559 @router.get(f"{sub}/featured")
1560 async def get_featured(request: Request):
1561 """The main design, and whether the caller may choose it."""
1562 access = Access(request)
1563 main = store.read_featured()
1564 if main is not None:
1565 names = directory.display_names(request, store.ud)
1566 main = {**main, "ownerName": names.get(main["owner"]) or main["owner"]}
1567 return {
1568 "featured": main,
1569 "isAdmin": access.admin,
1570 # So the UI can say "older than N days" without hardcoding N.
1571 "recentDays": store.recent_days if store.open_to_all else None,
1572 # So the UI knows to offer the STAR set and access requests.
1573 "curated": store.curated,
1574 }
1575
1576 def _require_admin(request: Request) -> str:
1577 access = Access(request)
1578 if not access.admin:
1579 raise HTTPException(
1580 status_code=403, detail=f"Only an admin can choose the main {store.noun}"
1581 )
1582 return access.viewer
1583
1584 @router.put(f"{sub}/featured")
1585 async def set_featured(request: Request, payload: FeaturedPayload):
1586 """Make a design the main one. Admins only."""
1587 viewer = _require_admin(request)
1588 ref = _resolve_doc(request, payload.owner, payload.id, need="read")
1589 data = {"owner": ref.owner, "id": ref.doc_id, "setBy": viewer, "setAt": _now_iso()}
1590 store.write_featured(data)
1591 # The main design is a STAR one. Written into the set, not just
1592 # implied by the pointer, so unsetting main leaves it there.
1593 if store.curated:
1594 _add_star(ref.owner, ref.doc_id, viewer)
1595 return {"featured": data, "isAdmin": True}
1596
1597 @router.delete(f"{sub}/featured")
1598 async def clear_featured(request: Request):
1599 """No main design. Admins only."""
1600 _require_admin(request)
1601 store.write_featured(None)
1602 return {"featured": None, "isAdmin": True}
1603
1604 return router
#define max(a, b)
Definition Arduino.h:43
None write_featured(self, dict|None data)
Definition documents.py:254
None write_star(self, list[dict] entries)
Definition documents.py:311
dict|None find_record(self, str owner, str doc_id)
Definition documents.py:229
str|None lock_holder(self, dict record)
Definition documents.py:314
Iterator[None] star_lock(self)
Definition documents.py:269
set[tuple[str, str]] read_star(self)
Definition documents.py:293
None require_lock_on(self, dict record, str doc_id, str viewer)
Definition documents.py:334
Path index_path(self, str user, *, bool create=False)
Definition documents.py:213
None claim_for_write(self, str owner, str doc_id, str viewer)
Definition documents.py:381
Iterator[None] index_lock(self, str user)
Definition documents.py:364
list[dict] load_index(self, str user)
Definition documents.py:216
None save_index(self, str user, list[dict] index)
Definition documents.py:349
None _write_json_atomic(Path p, object data)
Definition documents.py:129
APIRouter make_router(DesignStore store, str prefix, str sub="")
Definition documents.py:445