Repository navigation
feat(dispatch): sovereign dispatch engine, email carrier, and tri-mode zen story player - #35
Conversation
…-mode zen story player
There was a problem hiding this comment.
🟡 Changes recommended
Critical security and concurrency findings, plus correctness defects, must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds a tokenized dispatch system with a standalone tri-mode story player, RSVP/email APIs, file-backed lifecycle storage, and device/storage capability calculations.
Changes:
- Adds
/p/:tokenCard, Deck, and Prose modes with standalone routing. - Adds dispatch persistence, token lifecycle, originals, RSVP, and email preview support.
- Adds display/storage calculators and integration tests.
File summaries
| File | Reviewed changes and final comments |
|---|---|
frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue |
Tri-mode player UI. Moderate: auto-advance can show confirmation without submitting; Prose number inputs use text; non-OK responses can leave a blank canvas; reused route tokens show stale data; question IDs collide; fullscreen state is unsynchronized; invalid modes can render blank; CRLF and alternate question syntax are unsupported. Nit: styles bypass USX tokens, tab semantics are incomplete, and three raw Unicode icons should use UIcon. |
frontend-vue/src/surfaces/dispatch/DispatchStoryView.test.ts |
Frontend player behavior and mode tests. |
frontend-vue/src/router/index.ts |
/p/:token route. |
frontend-vue/src/layouts/AppShell.vue |
Standalone layout support. |
backend/tests/test_dispatch_api.py |
Dispatch API lifecycle tests. |
backend/app/services/email_dispatch.py |
HTML/plain-text email compilation. |
backend/app/services/dispatch_store.py |
Moderate: expiry is exclusive; token-index write failures still report success; burn-after-read does not occur on close. Critical: token-index and RSVP read/append/write operations are unsynchronized and can lose data or corrupt storage. |
backend/app/services/device_calc.py |
Moderate: negative sys_bytes can produce capacities exceeding device size. Nit: promised GIF/BOB blitting, palette quantization, and amber_crt purge are not implemented in the changed files. |
backend/app/api/routes.py |
Dispatch route registration. |
backend/app/api/dispatch_api.py |
Critical: public creation can fill the Vault without authentication or size limits; previews expose guest tokens; RSVP responses lack authorization. Moderate: story Markdown is inserted unsafely into HTML, mode_default is not validated, non-object JSON can cause 500s, and email URLs are not absolute. |
Review details
Suppressed comments (12)
backend/app/api/dispatch_api.py:142
int(data.get("width", 1920))turns malformed or explicitly null input into an uncaughtValueError/TypeErrorand a 500 response. Validate numeric viewport fields and return a 400 error for invalid client input.
w = int(data.get("width", 1920))
h = int(data.get("height", 1080))
backend/app/api/dispatch_api.py:156
- The storage calculator has the same uncaught conversion path for malformed or null
total_bytes/sys_bytes, so invalid client input becomes a 500. Validate these fields and return a 400 response before calling the calculator.
total_bytes = int(data.get("total_bytes", 4_000_000_000))
sys_bytes = data.get("sys_bytes")
res = calc_storage_capacity(total_bytes, int(sys_bytes) if sys_bytes is not None else None)
backend/app/api/dispatch_api.py:182
- The host-side create, preview, and response routes are registered without any identity/API-key or ownership check. This app defaults
UCORE_REQUIRE_AUTHto false andcreate_appinstalls only CORS/budget middleware, so a remote caller can write arbitrary dispatches under~/Vaultand read all RSVP payloads by dispatch id. Add explicit host authorization/ownership checks to these endpoints; keep only the token-based guest paths public.
app.router.add_post("/api/dispatch/create", handle_create_dispatch)
app.router.add_get("/api/dispatch/token/{token}", handle_get_by_token)
app.router.add_post("/api/dispatch/rsvp/{token}", handle_submit_rsvp)
app.router.add_get("/api/dispatch/preview-email/{id}", handle_preview_email)
app.router.add_get("/api/dispatch/responses/{id}", handle_get_responses)
backend/app/services/device_calc.py:64
- The PR description promises a headless GIF/animated BOB blitter with palette quantization and an
amber_crtpurge, but this change only adds a static BOB memory budget; no blitter, quantizer, or purge implementation is present in the changed files. If those are acceptance criteria, the PR is incomplete; otherwise update the description.
# BOB Blitter Object Memory Budget
# Typical 64x64 BOB with 12 frames at 1 byte/pixel (indexed palette)
typical_bob_ram = 64 * 64 * 12 * 1
backend/app/services/dispatch_store.py:182
burn_after_readis only applied when RSVP submission succeeds; a guest can open the story, close the tab without submitting, and reopen the same token indefinitely. That contradicts the one-time/“dissolve upon closing” behavior exposed by the player. Add an explicit consume/close-beacon flow or define and enforce the intended burn point consistently.
# Burn token if configured
if manifest.get("burn_after_read"):
manifest["status"] = "burned"
(dispatch_dir / "dispatch.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue:400
- Only 404/not-found responses are handled before the active branch. A 500/503 JSON response falls through, sets an empty manifest, and leaves the user on a blank active canvas instead of showing an error; handle other non-OK responses before assigning active state.
if (res.status === 404 || data.status === "not_found") {
error.value = "Dispatch not found or revoked.";
status.value = "not_found";
} else if (data.status === "burned" || data.status === "expired") {
frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue:70
- The fullscreen control uses a raw Unicode glyph as a UI icon (
⛶). The uCore frontend convention uses theUIconMaterial Symbols component for these controls, so replace the glyph (and the matching decorative glyphs in this surface) withUIconrather than embedding symbols in button text.
<button class="dispatch-btn-sm" @click="toggleFullscreen" title="Fullscreen (F)">
{{ isFullscreen ? 'Exit ⛶' : 'Fullscreen ⛶' }}
frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue:98
- The confirmation card repeats a raw Unicode glyph (
✦) as a UI icon. Use the sharedUIconcomponent here so the icon follows the frontend's Material Symbols and accessibility conventions.
<div class="dispatch-card__hero-icon">✦</div>
frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue:6
- This tombstone uses a raw Unicode glyph as an icon (
✦). The frontend convention is to render UI icons throughUIconMaterial Symbols so they inherit the shared icon/accessibility behavior; replace the glyph instead of embedding it as text.
<div class="dispatch-tombstone__icon">✦</div>
frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue:408
- The server accepts arbitrary
mode_defaultvalues, and this runtime assignment bypasses the TypeScript union. A malformed or future unsupported value makes none of thecard/deck/prosebranches render, leaving a blank dispatch canvas. Validate the mode when creating the manifest or fall back tocardhere.
if (manifest.value.mode_default) {
activeMode.value = manifest.value.mode_default;
frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue:276
- Splitting only on
\n---\nfails for CRLF Markdown. A story authored on Windows remains one card, so its questions are not associated with steps and card navigation does not work as intended.
return rawMarkdown.value
.split(/\n---\n/)
.map((chunk) => chunk.trim())
.filter(Boolean);
frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue:303
- The parser recognizes only
? [ ] ..., while the backend lifecycle test creates? Confirm: [ ]. That story renders without a choice control in the player, so the response is silently skipped; define one syntax and use it consistently or parse both forms.
// Choice question: ? [ ] Option A / Option B
if (trimmed.startsWith("? [ ]")) {
const parts = trimmed.replace("? [ ]", "").split("/").map((s) => s.trim());
- Files reviewed: 10/10 changed files
- Comments generated: 19
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…d RSVP ledger to Binder Panel
…on, and offline export
…nc, and host automation
…ith WebSocket broadcast, save-to-binder & task promotion
…back for CI environments
Consolidates universal standards across uDos:
/p/:token) with Zen Card (Typeform auto-advance), 10-Foot Deck (Marp TV), and Prose Reader modesamber_crtand preserves originals under~/Vault/dispatches/<id>/originals/.