taskops-cli 0.5.0__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ARCHITECTURE.md +96 -1
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/CHANGELOG.md +34 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/CLAUDE.md +11 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/PKG-INFO +22 -2
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/README.md +21 -1
- taskops_cli-0.5.1/smoke.sh +164 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_version.py +1 -1
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/auth.py +19 -1
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/handler.py +12 -11
- taskops_cli-0.5.1/src/taskops/http/routes.py +32 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/static.py +19 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/ui/app.js +5 -5
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/ui/index.html +4 -2
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_topology.py +180 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/index.html +4 -2
- taskops_cli-0.5.1/ui/smoke/sections/board-root-mount.tsx +75 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/client.ts +24 -3
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/main.tsx +6 -8
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.claude/settings.json +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.gitattributes +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.github/workflows/ci.yml +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.github/workflows/workflow.yml +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.gitignore +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.mcp.json +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/cache.sqlite +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/cache.sqlite-shm +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/cache.sqlite-wal +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/events.jsonl +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/live.sqlite +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/live.sqlite-shm +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board/live.sqlite-wal +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/board.json +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/reports/lifecycle-chapter.html +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/reports/reports-chapter.html +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/.taskops/ui.lock +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/pyproject.toml +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/__init__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_clock.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_errors.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_ids.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_json.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_locate.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/_wire.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/board.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/__init__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/__main__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/admin.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/claude.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/commands.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/enrol.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/github.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/grants.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/hooks.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/main.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/operate.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/paging.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/parser.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/pull.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/push.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/remote.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/rm.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/serving.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/team.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/watch.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/window.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/cli/wording.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/__init__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/actors.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/challenge.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/chapters.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/event.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/forge.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/graph.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/holding.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/hours.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/kinds.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/machine.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/mentions.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/replay.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/reports.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/review.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/scope.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/seams.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/core/types.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/__init__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/bind.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/catchup.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/claudefiles.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/diff.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/install.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/landing.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/mirror.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/patch.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/remote.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/run.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/sig.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/trailer.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/gitwork/trees.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/__init__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/admin.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/feed.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/gitdoor.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/grants.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/ingest.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/login.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/members.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/mounts.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/removal.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/repos.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/rpc.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/scoped.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/server.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/stale.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/upstream.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/http/watcher.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/identity.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/__init__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/__main__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/activity.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/before.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/boards.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/boardview.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/brief.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/chapter.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/dossier.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/fields.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/gitmoves.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/hello.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/integrate.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/orders.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/render.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/schema.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/server.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/thread.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/mcp/tools.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/py.typed +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/session.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/__init__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/cache.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/creds.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/handover.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/live.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/log.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/pubkeys.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/reviews.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/server.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/store/stores.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/ui/style.css +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/__init__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_args.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_cards.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_chapter.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_context.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_facts.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_mentions.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_rows.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_stories.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_waiting.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/_windows.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/activity.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/assign.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/card.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/events.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/filed.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/plan.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/project.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/pulse.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/record.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/report.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/review.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/take.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/src/taskops/verbs/update.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/__init__.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/conftest.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_architecture.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_claude.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_core.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_git.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_mcp.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_mirror.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_report.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_store.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_ui.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_verbs.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/test_window.py +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/tests/ui_harness.js +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/build.mjs +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/package-lock.json +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/package.json +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/fixture.json +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/main.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/run.mjs +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/actors-window-filter.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/actors.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/all-chapters-scope.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/board-columns.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/board-flow-view.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/board-motion-and-avatars.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/chapter-completion-derivation.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/chapter-landed.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/chapter-story-grid.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/chapters-open.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/comment-box.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/comment-toasts-model.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/comment-toasts-stack.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/diff-page.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/dossier.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/event-stream.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/feed-reconnect.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/forge-links.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/forge-opens-the-board.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/git-diff.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/hosted-window.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/monitor-panes.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/overlay-escape.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/report-markdown.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/report-sandbox.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/reviewing-skew.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/section.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/swarm-topology.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/thread-closing-note.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections/worktrees-index.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/smoke/sections.mjs +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/App.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/completed.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/actors/Daysheet.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/actors/DevPanel.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/CardTile.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/Column.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/ViewToggle.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/flip.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/board/useFlip.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/CommentBox.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/Drawer.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/Patch.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/Sections.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/Thread.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/split.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/card/tokens.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/AvatarStack.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/Header.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/KpiRail.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/MilestonePicker.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/chrome/TabNav.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/flow/FlowView.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/flow/layout.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Chapter.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/DependencyChain.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/EditSurface.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/EventStream.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/LeaseHealth.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/LiveLeases.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Mentions.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Pane.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Swarm.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/Throughput.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/monitor/panels.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/reports/ReportFrame.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/Avatar.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/Markdown.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/Overlay.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/Segmented.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/shared/overlayStack.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/story/ChapterStory.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/story/stats.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/toasts/ToastStack.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/toasts/model.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/components/toasts/useToasts.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/format.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/hoursWindow.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/links.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/markdown.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Actors.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Board.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Monitor.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Reports.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/WorktreeDiff.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/pages/Worktrees.tsx +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/theme/theme.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/theme/tokens.css +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/types.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/useBoard.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/src/useEvents.ts +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/styles/index.css +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/ui/tsconfig.json +0 -0
- {taskops_cli-0.5.0 → taskops_cli-0.5.1}/uv.lock +0 -0
|
@@ -1508,7 +1508,10 @@ construction and never sniffed per request** (`Mounts(repo=…)`,
|
|
|
1508
1508
|
The `/ui/` column arrived later, and it is the same `repo` deciding both — see
|
|
1509
1509
|
"API ONLY is now literal" below. Both cells of the `serve` row are narrowed a
|
|
1510
1510
|
second time by "The hosted window" below: a board that DECLARED a forge may be
|
|
1511
|
-
answered from a bare read-only mirror of it, and only then.
|
|
1511
|
+
answered from a bare read-only mirror of it, and only then. And the page's ADDRESS moved
|
|
1512
|
+
one notch after that: on a serve-mode host the last column is the board's own
|
|
1513
|
+
`/<board>/`, with `/<board>/ui/` kept as an alias — "The board's own address IS
|
|
1514
|
+
the page" at the end of this section.
|
|
1512
1515
|
|
|
1513
1516
|
A viewer meets that gap whenever this clone cannot answer — a ref not fetched
|
|
1514
1517
|
yet, a shallow checkout, a commit from before this repo had the branch — and
|
|
@@ -1823,6 +1826,98 @@ starts from the server's real shape, and `null` (fewer than two ready cards,
|
|
|
1823
1826
|
nothing to decide) is deliberately a different state from absent (a board older
|
|
1824
1827
|
than the wave).
|
|
1825
1828
|
|
|
1829
|
+
### A bare `Bearer` is NO credential, not an unknown one (2026-08-31)
|
|
1830
|
+
|
|
1831
|
+
The hosted window shipped on 2026-08-30 and was unusable the next morning for
|
|
1832
|
+
the exact reader it was built for: a PUBLIC board, opened from its own hosted
|
|
1833
|
+
page, answered *"unknown credential — run: taskops join"*. Nothing about
|
|
1834
|
+
visibility was wrong. The page simply had no token, so the browser sent
|
|
1835
|
+
`Authorization: Bearer ` with an empty value; `auth.token_in`'s `removeprefix`
|
|
1836
|
+
did not match the header without its space-and-value, and it returned the
|
|
1837
|
+
literal string `Bearer` as if that were somebody's token. `creds.check` then did
|
|
1838
|
+
its job perfectly on a token nobody ever minted.
|
|
1839
|
+
|
|
1840
|
+
So the fault was never in the visibility rule and never in the client. It was in
|
|
1841
|
+
the one place that decides what a header MEANS:
|
|
1842
|
+
|
|
1843
|
+
> **An `Authorization` header that carries no value is the ABSENCE of a
|
|
1844
|
+
> credential, identical to sending no header at all. A header that carries a
|
|
1845
|
+
> value is a claim, and a claim that does not check out is refused with the
|
|
1846
|
+
> sentence it has always used.**
|
|
1847
|
+
|
|
1848
|
+
Both halves are load-bearing, and the second is why this is not the tempting
|
|
1849
|
+
one-line generalisation. "Treat anything unparseable as anonymous" would make a
|
|
1850
|
+
typo'd token read a private board as far as its visibility allows and, worse,
|
|
1851
|
+
would turn an expired credential into a silent downgrade instead of the loud
|
|
1852
|
+
`unknown credential` it has always been. Empty is not wrong; wrong is wrong —
|
|
1853
|
+
and the two refusals stay different sentences: a caller who presented NOTHING to
|
|
1854
|
+
a private board is told how to join, a caller who presented something unknown is
|
|
1855
|
+
told it is unknown.
|
|
1856
|
+
|
|
1857
|
+
It lives in ONE extractor (`http/auth.py::token_in`) because every door — `rpc`,
|
|
1858
|
+
`feed`, `git`, the static page — asks that function and nothing else. A second
|
|
1859
|
+
door with its own opinion about a header is how the fleet ends up with two
|
|
1860
|
+
credential rules, which is the failure mode §19 exists to prevent. `taskops
|
|
1861
|
+
ui`'s window never saw this: it mints a token before it opens a tab, so the only
|
|
1862
|
+
client that sends an empty bearer is the hosted page, and the hosted page is
|
|
1863
|
+
younger than the extractor.
|
|
1864
|
+
|
|
1865
|
+
The lesson generalises past auth: **anonymous worked only because no client
|
|
1866
|
+
existed**. Every test that pinned "no header reads a public board" sent no
|
|
1867
|
+
header, because the only readers were `curl` and the MCP client. A browser
|
|
1868
|
+
cannot express "no header" through a fetch wrapper that always sets one — so a
|
|
1869
|
+
contract that is only ever exercised by hand-written clients is not pinned,
|
|
1870
|
+
it is merely unvisited. `tests/test_topology.py` now sends the bare header
|
|
1871
|
+
alongside the absent one, on a public board AND a private one, which is the pair
|
|
1872
|
+
that says which of the two rules above is being tested.
|
|
1873
|
+
|
|
1874
|
+
### The board's own address IS the page (2026-08-31)
|
|
1875
|
+
|
|
1876
|
+
The same report carried a second complaint, and it is a design one: the page was
|
|
1877
|
+
at `/<board>/ui/`. A board host serves exactly one thing to a human, and it had
|
|
1878
|
+
been given a sub-path — so the URL a person pastes says `ui` twice over (once in
|
|
1879
|
+
the product's name for the thing, once in the path) and the shortest address on
|
|
1880
|
+
the host, `/<board>/`, answered 404. Whatever a host serves to people belongs at
|
|
1881
|
+
its own address; a prefix is for the doors people never type.
|
|
1882
|
+
|
|
1883
|
+
> **A board's page is served at `/<board>/` (and `/<board>`, bare). The MACHINE
|
|
1884
|
+
> doors move under `/<board>/api/…` — `rpc`, `git/…`, `feed`, `invite/redeem`.**
|
|
1885
|
+
|
|
1886
|
+
Two facts make that router unambiguous rather than a guess per request. The
|
|
1887
|
+
prefix is stripped ONCE, in `http/routes.py::split`, shared by GET and POST, so
|
|
1888
|
+
`/<board>/api/rpc` IS `/<board>/rpc` — same handler, same credential, same
|
|
1889
|
+
words, and no door can drift from the other spelling. And the page's own
|
|
1890
|
+
relative links (`./app.js`, `./style.css` off `index.html`) now arrive as board
|
|
1891
|
+
tails, which the router separates from doors by a CLOSED SET: `static.py::asset`
|
|
1892
|
+
asks the packaged bundle what files it actually ships, flat, with a suffix in
|
|
1893
|
+
`TYPES`. None is named `rpc`, `git`, `feed`, `ui` or `api`, so an asset can
|
|
1894
|
+
never shadow a door and a door never shadows an asset — and a bundle that grows
|
|
1895
|
+
a font ships without a router edit. The host's own one-segment doors (`/login`,
|
|
1896
|
+
`/rpc`, `/healthz`) are untouched, because the strip lives in the TAIL and never
|
|
1897
|
+
sees them; a board literally named `api` still answers at `/api/…`.
|
|
1898
|
+
|
|
1899
|
+
**The 0.5.0 spellings are kept, not deprecated** — `/<board>/ui/`,
|
|
1900
|
+
`/<board>/rpc`, `/<board>/git/…`, `/<board>/feed` all answer, unprefixed and
|
|
1901
|
+
without a redirect. Not out of politeness: links to them were pasted the day
|
|
1902
|
+
they existed, `.mcp.json` files and agents are configured against them, four
|
|
1903
|
+
legacy-bearer production boards speak them, and `taskops ui`'s upstream forward
|
|
1904
|
+
builds them. A redirect would have been the tidier story and would have broken
|
|
1905
|
+
every POST that follows one badly. So the rule this chapter adds about old
|
|
1906
|
+
addresses is the one it also obeys: **an address that ever worked keeps
|
|
1907
|
+
answering, and the new one is where the page POINTS people.** `ui/src/client.ts`
|
|
1908
|
+
derives its base from the page's own location (`baseOf`) precisely so the page
|
|
1909
|
+
never hardcodes which of the two it was loaded from.
|
|
1910
|
+
|
|
1911
|
+
**Both faults are pinned live, not only in the suite.** `smoke.sh` at the repo
|
|
1912
|
+
root is the re-runnable list — every assertion in it is one of these two reports
|
|
1913
|
+
or something 0.5.0 already promised, read-only, and it exits on the first
|
|
1914
|
+
failure:
|
|
1915
|
+
|
|
1916
|
+
```sh
|
|
1917
|
+
sh smoke.sh # taskops.bernardocastro.dev, taskops-v2
|
|
1918
|
+
sh smoke.sh <host> <board> <private-board>
|
|
1919
|
+
```
|
|
1920
|
+
|
|
1826
1921
|
---
|
|
1827
1922
|
|
|
1828
1923
|
## 17. Deployed — taskops.bernardocastro.dev is v2 (2026-08-08)
|
|
@@ -3,6 +3,40 @@
|
|
|
3
3
|
The source of truth for release notes — GitHub Releases are extracted from
|
|
4
4
|
here, never written twice.
|
|
5
5
|
|
|
6
|
+
## 0.5.1 — the hosted window, actually usable
|
|
7
|
+
|
|
8
|
+
0.5.0 shipped a window nobody could use. Both faults were reported the next
|
|
9
|
+
morning against the live host, and both were contract-level: the URL people
|
|
10
|
+
paste, and the credential rule every door shares.
|
|
11
|
+
|
|
12
|
+
- **A public board was unreadable from its own hosted page.** The client sends
|
|
13
|
+
`Authorization: "Bearer " + token()`; with no token that header arrives as
|
|
14
|
+
`Bearer`, and `auth.token_in`'s `removeprefix("Bearer ")` did not match — so
|
|
15
|
+
the literal string `"Bearer"` came back AS the token and the reader was told
|
|
16
|
+
"unknown credential — run: taskops join". Anonymous reads worked only when NO
|
|
17
|
+
Authorization header was sent at all, which no browser client does: the
|
|
18
|
+
anonymous path was only ever green because nothing but curl had walked it.
|
|
19
|
+
Scheme and value are now parsed as separate fields, the scheme
|
|
20
|
+
case-insensitive per RFC 7235, and a bearer scheme with no value is the
|
|
21
|
+
ABSENCE of a credential. Deliberately narrow: a header carrying any actual
|
|
22
|
+
value — wrong, expired, or under a scheme this server never spoke — is a
|
|
23
|
+
credential that WAS presented and still refuses exactly as before.
|
|
24
|
+
"Unparseable" is not "anonymous".
|
|
25
|
+
- **The board's own address IS the page.** `https://<host>/<board>/` (and
|
|
26
|
+
`/<board>`) serves the dashboard, with `app.js` and `style.css` beside it; the
|
|
27
|
+
machine doors also answer under `/<board>/api/{rpc,git,feed}`, stripped once
|
|
28
|
+
in `http/routes.py::split` so no door can drift from another. A sub-path was
|
|
29
|
+
wrong for the primary thing a board host serves.
|
|
30
|
+
- **Nothing that worked in 0.5.0 stopped working.** `/<board>/ui/`,
|
|
31
|
+
`/<board>/rpc`, `/<board>/git/…` and `/<board>/feed` all still answer — agents,
|
|
32
|
+
the MCP client, `taskops ui`'s forward and the legacy production boards speak
|
|
33
|
+
them, and a link somebody already pasted is a contract, not a legacy.
|
|
34
|
+
- **`smoke.sh`** — the live, read-only smoke for a board host: the page at the
|
|
35
|
+
board root, a bare `Bearer` reading a public board, a private board still
|
|
36
|
+
refusing, a card branch's diff rendered from the forge mirror. Every line is
|
|
37
|
+
there because it failed once in production, and each says which report it
|
|
38
|
+
came from.
|
|
39
|
+
|
|
6
40
|
## 0.5.0 — the hosted window
|
|
7
41
|
|
|
8
42
|
- **A board can be READ without a clone.** Until now the dashboard was only ever
|
|
@@ -228,6 +228,17 @@ what it exists to report, not a fault.
|
|
|
228
228
|
- One card rebuilds the bundle at the end of a wave. N cards rebuilding it is
|
|
229
229
|
N-1 conflicts by construction.
|
|
230
230
|
|
|
231
|
+
**The hosted page is at the board's OWN address** — `https://<host>/<board>/`
|
|
232
|
+
(and `/<board>`), its assets beside it, the machine doors under
|
|
233
|
+
`/<board>/api/{rpc,git,feed}`. 0.5.0's spellings (`/<board>/ui/`,
|
|
234
|
+
`/<board>/rpc`, `/<board>/git/…`, `/<board>/feed`) still answer and are a
|
|
235
|
+
contract, not a legacy — ARCHITECTURE §16, "The board's own address IS the
|
|
236
|
+
page". The page derives its base from its own location
|
|
237
|
+
(`ui/src/client.ts::baseOf`) and hardcodes neither. `sh smoke.sh` re-derives all
|
|
238
|
+
of it against the live host, plus the credential rule a browser exposed:
|
|
239
|
+
**an `Authorization` header with no value is the ABSENCE of a credential, a
|
|
240
|
+
header with a wrong value still refuses loudly** (`http/auth.py::token_in`).
|
|
241
|
+
|
|
231
242
|
## Never re-introduce
|
|
232
243
|
|
|
233
244
|
Each has its line in ARCHITECTURE.md §11 saying what it cost and where it is
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: taskops-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: A shared work board for teams of coding agents, with a human who decides.
|
|
5
5
|
Project-URL: Repository, https://github.com/bernatch22/taskops
|
|
6
6
|
License-Expression: MIT
|
|
@@ -70,7 +70,7 @@ importable leaves commits un-stamped, silently.
|
|
|
70
70
|
taskops init a local board in this repo
|
|
71
71
|
taskops join [<name>] [--invite <id>] join a board, install the hooks
|
|
72
72
|
taskops remote add <url> the host this checkout operates, like git's origin
|
|
73
|
-
taskops serve host boards —
|
|
73
|
+
taskops serve host boards — the page and /git open per declared forge
|
|
74
74
|
taskops server init bootstrap THIS host: its owner and their ssh key
|
|
75
75
|
taskops board create|ls|push|pull|rm the boards on a host
|
|
76
76
|
taskops board visibility|forge who may read one · which repo's team is enrolled
|
|
@@ -389,6 +389,26 @@ never asks the server for the bytes. A host running `taskops serve` answers
|
|
|
389
389
|
hosted window, for the reader with no clone. A board that never declared one
|
|
390
390
|
refuses with a sentence naming `taskops board forge <owner>/<repo>`.
|
|
391
391
|
|
|
392
|
+
**The hosted page is at the board's OWN address.** `https://<host>/<board>/`
|
|
393
|
+
IS the dashboard — not a sub-path under it — and the machine doors sit under a
|
|
394
|
+
prefix that can never collide with a page asset:
|
|
395
|
+
|
|
396
|
+
```
|
|
397
|
+
https://<host>/<board>/ the page (also /<board>, no slash)
|
|
398
|
+
https://<host>/<board>/app.js its assets style.css, index.html
|
|
399
|
+
https://<host>/<board>/api/rpc the verbs also /<board>/rpc
|
|
400
|
+
https://<host>/<board>/api/git/… diffs from the mirror also /<board>/git/…
|
|
401
|
+
https://<host>/<board>/api/feed the live feed also /<board>/feed
|
|
402
|
+
https://<host>/healthz the host itself
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
The right-hand spellings are 0.5.0's and they keep answering, unprefixed and
|
|
406
|
+
un-redirected — including `/<board>/ui/`, which was the page's address for one
|
|
407
|
+
day. Links were pasted, agents and the MCP client are configured against them,
|
|
408
|
+
and `taskops ui`'s upstream forward speaks them, so they are a contract now
|
|
409
|
+
rather than a legacy. Re-derivable at any time with `sh smoke.sh <host>
|
|
410
|
+
<board>`.
|
|
411
|
+
|
|
392
412
|
**Reading one, from the other side.** Everything above is the author's half; a
|
|
393
413
|
reader needs no ceremony at all. Three doors onto the same bytes:
|
|
394
414
|
|
|
@@ -58,7 +58,7 @@ importable leaves commits un-stamped, silently.
|
|
|
58
58
|
taskops init a local board in this repo
|
|
59
59
|
taskops join [<name>] [--invite <id>] join a board, install the hooks
|
|
60
60
|
taskops remote add <url> the host this checkout operates, like git's origin
|
|
61
|
-
taskops serve host boards —
|
|
61
|
+
taskops serve host boards — the page and /git open per declared forge
|
|
62
62
|
taskops server init bootstrap THIS host: its owner and their ssh key
|
|
63
63
|
taskops board create|ls|push|pull|rm the boards on a host
|
|
64
64
|
taskops board visibility|forge who may read one · which repo's team is enrolled
|
|
@@ -377,6 +377,26 @@ never asks the server for the bytes. A host running `taskops serve` answers
|
|
|
377
377
|
hosted window, for the reader with no clone. A board that never declared one
|
|
378
378
|
refuses with a sentence naming `taskops board forge <owner>/<repo>`.
|
|
379
379
|
|
|
380
|
+
**The hosted page is at the board's OWN address.** `https://<host>/<board>/`
|
|
381
|
+
IS the dashboard — not a sub-path under it — and the machine doors sit under a
|
|
382
|
+
prefix that can never collide with a page asset:
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
https://<host>/<board>/ the page (also /<board>, no slash)
|
|
386
|
+
https://<host>/<board>/app.js its assets style.css, index.html
|
|
387
|
+
https://<host>/<board>/api/rpc the verbs also /<board>/rpc
|
|
388
|
+
https://<host>/<board>/api/git/… diffs from the mirror also /<board>/git/…
|
|
389
|
+
https://<host>/<board>/api/feed the live feed also /<board>/feed
|
|
390
|
+
https://<host>/healthz the host itself
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
The right-hand spellings are 0.5.0's and they keep answering, unprefixed and
|
|
394
|
+
un-redirected — including `/<board>/ui/`, which was the page's address for one
|
|
395
|
+
day. Links were pasted, agents and the MCP client are configured against them,
|
|
396
|
+
and `taskops ui`'s upstream forward speaks them, so they are a contract now
|
|
397
|
+
rather than a legacy. Re-derivable at any time with `sh smoke.sh <host>
|
|
398
|
+
<board>`.
|
|
399
|
+
|
|
380
400
|
**Reading one, from the other side.** Everything above is the author's half; a
|
|
381
401
|
reader needs no ceremony at all. Three doors onto the same bytes:
|
|
382
402
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The live smoke for a taskops board HOST. Read-only, no credential, no state:
|
|
3
|
+
# every request below is a GET or a `board` read, so it is safe to run against
|
|
4
|
+
# production at any time, including from a laptop that never joined the board.
|
|
5
|
+
#
|
|
6
|
+
# sh smoke.sh # the author's host, defaults below
|
|
7
|
+
# sh smoke.sh <host> <public-board> <private-board> <card-branch>
|
|
8
|
+
#
|
|
9
|
+
# Every assertion is here because it FAILED once, in production, against a real
|
|
10
|
+
# reader — the comment above each one says which report it comes from. Nothing
|
|
11
|
+
# is asserted because it seemed prudent: a smoke list that grows by taste stops
|
|
12
|
+
# being read, and a green line that never could have gone red is a lie about
|
|
13
|
+
# coverage. It exits non-zero on the FIRST failure, so the first red line is the
|
|
14
|
+
# whole diagnosis.
|
|
15
|
+
#
|
|
16
|
+
# `pass` prints what was actually observed, never just OK — a smoke you cannot
|
|
17
|
+
# read the evidence out of is a smoke you end up re-running by hand anyway.
|
|
18
|
+
|
|
19
|
+
set -eu
|
|
20
|
+
|
|
21
|
+
HOST=${1:-taskops.bernardocastro.dev}
|
|
22
|
+
BOARD=${2:-taskops-v2}
|
|
23
|
+
PRIVATE=${3:-axion}
|
|
24
|
+
BRANCH=${4:-tk-32d2ba}
|
|
25
|
+
|
|
26
|
+
BASE="https://$HOST"
|
|
27
|
+
C="curl -sS -m 15"
|
|
28
|
+
|
|
29
|
+
fail() { printf '\nFAIL %s\n %s\n' "$1" "$2" >&2; exit 1; }
|
|
30
|
+
pass() { printf 'ok %s\n %s\n' "$1" "$2"; }
|
|
31
|
+
head_ln() { printf '\n-- %s\n' "$1"; }
|
|
32
|
+
|
|
33
|
+
# ── (e) the host itself ───────────────────────────────────────────────────────
|
|
34
|
+
# First, because every failure below is ambiguous until this one is green: a
|
|
35
|
+
# 502 from the proxy and a broken board answer the same shape of nothing.
|
|
36
|
+
head_ln "healthz — the host is up"
|
|
37
|
+
body=$($C "$BASE/healthz") || fail "GET /healthz" "no answer from $BASE"
|
|
38
|
+
case $body in
|
|
39
|
+
*'"ok": true'*|*'"ok":true'*) pass "GET /healthz" "$body" ;;
|
|
40
|
+
*) fail "GET /healthz" "$body" ;;
|
|
41
|
+
esac
|
|
42
|
+
|
|
43
|
+
# ── (a) the board's own address IS the page ───────────────────────────────────
|
|
44
|
+
# Reported 2026-08-31: the page was at /<board>/ui/ and /<board>/ was a 404 —
|
|
45
|
+
# the shortest address on the host, the one a person types, answered nothing.
|
|
46
|
+
# Asserted on the BARE path too: /<board> without the trailing slash is what
|
|
47
|
+
# gets pasted into Slack, and a router that only handles one of the two looks
|
|
48
|
+
# fine in a browser (it follows the redirect) and breaks every copied link.
|
|
49
|
+
head_ln "the page at the board's own address"
|
|
50
|
+
for path in "/$BOARD/" "/$BOARD"; do
|
|
51
|
+
page=$($C "$BASE$path") || fail "GET $path" "no answer"
|
|
52
|
+
case $page in
|
|
53
|
+
*'app.js'*) pass "GET $path" "html referencing app.js ($(printf %s "$page" | wc -c | tr -d ' ') bytes)" ;;
|
|
54
|
+
*) fail "GET $path" "not the page: $(printf %s "$page" | head -c 200)" ;;
|
|
55
|
+
esac
|
|
56
|
+
done
|
|
57
|
+
# And its assets resolve from that address: the page's own links are relative
|
|
58
|
+
# (./app.js off index.html), so they arrive as board TAILS and the router has to
|
|
59
|
+
# tell them from the machine doors. If this 404s, the page loads unstyled and
|
|
60
|
+
# blank with nothing in the server log to say why.
|
|
61
|
+
for asset in app.js style.css; do
|
|
62
|
+
code=$($C -o /dev/null -w '%{http_code} %{content_type}' "$BASE/$BOARD/$asset") \
|
|
63
|
+
|| fail "GET /$BOARD/$asset" "no answer"
|
|
64
|
+
case $code in
|
|
65
|
+
200*) pass "GET /$BOARD/$asset" "$code" ;;
|
|
66
|
+
*) fail "GET /$BOARD/$asset" "$code" ;;
|
|
67
|
+
esac
|
|
68
|
+
done
|
|
69
|
+
|
|
70
|
+
# ── (b) a bare `Bearer` reads a PUBLIC board ──────────────────────────────────
|
|
71
|
+
# THE fault of this chapter, shipped in 0.5.0 and reported 2026-08-31: the page
|
|
72
|
+
# sends `Authorization: "Bearer " + token()`, so with no token the header
|
|
73
|
+
# arrives as `Bearer`, and the old extractor returned the literal string
|
|
74
|
+
# "Bearer" as somebody's token — "unknown credential — run: taskops join" to a
|
|
75
|
+
# reader who presented nothing. Anonymous reads worked only with NO header at
|
|
76
|
+
# all, which no browser client can express. Both shapes are asserted: the
|
|
77
|
+
# no-header stranger (what the suite always tested) and the bare bearer (what
|
|
78
|
+
# every browser actually sends).
|
|
79
|
+
head_ln "a bare Bearer is no credential — public board reads"
|
|
80
|
+
rpc() { # rpc <path> [<authorization value>]
|
|
81
|
+
if [ $# -gt 1 ]; then
|
|
82
|
+
$C -o /dev/null -w '%{http_code}' -X POST "$BASE$1" \
|
|
83
|
+
-H 'Content-Type: application/json' -H "Authorization: $2" \
|
|
84
|
+
-d '{"verb": "board", "args": {}}'
|
|
85
|
+
else
|
|
86
|
+
$C -o /dev/null -w '%{http_code}' -X POST "$BASE$1" \
|
|
87
|
+
-H 'Content-Type: application/json' -d '{"verb": "board", "args": {}}'
|
|
88
|
+
fi
|
|
89
|
+
}
|
|
90
|
+
code=$(rpc "/$BOARD/api/rpc" "Bearer") || fail "POST /$BOARD/api/rpc (bare Bearer)" "no answer"
|
|
91
|
+
[ "$code" = 200 ] || fail "POST /$BOARD/api/rpc (bare Bearer)" "HTTP $code — the 0.5.0 fault is back"
|
|
92
|
+
pass "POST /$BOARD/api/rpc Authorization: Bearer" "HTTP 200"
|
|
93
|
+
code=$(rpc "/$BOARD/api/rpc") || fail "POST /$BOARD/api/rpc (no header)" "no answer"
|
|
94
|
+
[ "$code" = 200 ] || fail "POST /$BOARD/api/rpc (no header)" "HTTP $code"
|
|
95
|
+
pass "POST /$BOARD/api/rpc no Authorization" "HTTP 200"
|
|
96
|
+
# The other half of that fix, and the reason it is not a one-line
|
|
97
|
+
# generalisation: a credential that WAS presented and does not check out still
|
|
98
|
+
# refuses loudly. "Unparseable is anonymous" would turn an expired token into a
|
|
99
|
+
# silent read-only downgrade instead of a sentence naming the way back in.
|
|
100
|
+
body=$($C -X POST "$BASE/$BOARD/api/rpc" -H 'Content-Type: application/json' \
|
|
101
|
+
-H 'Authorization: Bearer not-a-token' -d '{"verb": "board", "args": {}}') \
|
|
102
|
+
|| fail "POST with a wrong token" "no answer"
|
|
103
|
+
case $body in
|
|
104
|
+
*'unknown credential'*) pass "POST /$BOARD/api/rpc Bearer not-a-token" "refused: unknown credential" ;;
|
|
105
|
+
*) fail "POST with a wrong token" "$body" ;;
|
|
106
|
+
esac
|
|
107
|
+
|
|
108
|
+
# ── (c) a PRIVATE board still refuses ─────────────────────────────────────────
|
|
109
|
+
# The wall moved for public boards; it must not have moved here. And the
|
|
110
|
+
# SENTENCE matters as much as the status: somebody who presented nothing is told
|
|
111
|
+
# how to join, which is a different answer from "unknown credential".
|
|
112
|
+
head_ln "a private board still refuses the same stranger"
|
|
113
|
+
body=$($C -X POST "$BASE/$PRIVATE/api/rpc" -H 'Content-Type: application/json' \
|
|
114
|
+
-H 'Authorization: Bearer' -d '{"verb": "board", "args": {}}') \
|
|
115
|
+
|| fail "POST /$PRIVATE/api/rpc" "no answer"
|
|
116
|
+
# Grepped for `no credential` and NOT for `taskops join`: both refusals name
|
|
117
|
+
# that command, so the looser grep passes on the 0.5.0 fault itself — where a
|
|
118
|
+
# private board answered `unknown credential` to a stranger who presented
|
|
119
|
+
# nothing. The whole point is WHICH sentence comes back.
|
|
120
|
+
case $body in
|
|
121
|
+
*'no credential'*) pass "POST /$PRIVATE/api/rpc Authorization: Bearer" "refused: no credential — run: taskops join" ;;
|
|
122
|
+
*) fail "POST /$PRIVATE/api/rpc" "expected the no-credential refusal, got: $body" ;;
|
|
123
|
+
esac
|
|
124
|
+
|
|
125
|
+
# ── (d) a card branch renders a diff from the forge mirror ────────────────────
|
|
126
|
+
# The hosted window's whole point: a reader with NO clone. Before the mirror,
|
|
127
|
+
# every diff fell through the UI's cascade to a forge link — a blank presented
|
|
128
|
+
# as the window. A tk-* branch on purpose: `master` is fetched by any clone, and
|
|
129
|
+
# the failure this catches is a mirror whose on-demand fetch never ran, which
|
|
130
|
+
# only shows on a ref the mirror did not have at clone time.
|
|
131
|
+
head_ln "a card branch diffs from the mirror"
|
|
132
|
+
body=$($C "$BASE/$BOARD/api/git/commit/$BRANCH") || fail "GET git/commit/$BRANCH" "no answer"
|
|
133
|
+
case $body in
|
|
134
|
+
*'"patch"'*) pass "GET /$BOARD/api/git/commit/$BRANCH" "a diff ($(printf %s "$body" | wc -c | tr -d ' ') bytes)" ;;
|
|
135
|
+
*) fail "GET git/commit/$BRANCH" "no diff: $(printf %s "$body" | head -c 300)" ;;
|
|
136
|
+
esac
|
|
137
|
+
|
|
138
|
+
# ── (f) every 0.5.0 address still answers ─────────────────────────────────────
|
|
139
|
+
# Links were pasted the day they existed, .mcp.json files are configured against
|
|
140
|
+
# them, four legacy-bearer production boards speak them, and `taskops ui`'s
|
|
141
|
+
# upstream forward builds them. An old address that 404s in silence is this
|
|
142
|
+
# chapter's own rule broken.
|
|
143
|
+
head_ln "the 0.5.0 addresses, unprefixed"
|
|
144
|
+
code=$($C -o /dev/null -w '%{http_code}' "$BASE/$BOARD/ui/") || fail "GET /$BOARD/ui/" "no answer"
|
|
145
|
+
[ "$code" = 200 ] || fail "GET /$BOARD/ui/" "HTTP $code"
|
|
146
|
+
pass "GET /$BOARD/ui/" "HTTP 200"
|
|
147
|
+
code=$(rpc "/$BOARD/rpc" "Bearer") || fail "POST /$BOARD/rpc" "no answer"
|
|
148
|
+
[ "$code" = 200 ] || fail "POST /$BOARD/rpc" "HTTP $code"
|
|
149
|
+
pass "POST /$BOARD/rpc" "HTTP 200"
|
|
150
|
+
code=$($C -o /dev/null -w '%{http_code}' "$BASE/$BOARD/git/commit/$BRANCH") \
|
|
151
|
+
|| fail "GET /$BOARD/git/commit/$BRANCH" "no answer"
|
|
152
|
+
[ "$code" = 200 ] || fail "GET /$BOARD/git/commit/$BRANCH" "HTTP $code"
|
|
153
|
+
pass "GET /$BOARD/git/commit/$BRANCH" "HTTP 200"
|
|
154
|
+
# /feed is a stream that never ends by design, so this reads the HEADERS and
|
|
155
|
+
# hangs up. curl's own timeout is therefore EXPECTED here and not a failure —
|
|
156
|
+
# what is asserted is the content type, which is what tells an SSE client it may
|
|
157
|
+
# start reading.
|
|
158
|
+
headers=$(curl -sS -m 3 -D - -o /dev/null "$BASE/$BOARD/feed" 2>/dev/null || true)
|
|
159
|
+
case $headers in
|
|
160
|
+
*event-stream*) pass "GET /$BOARD/feed" "text/event-stream" ;;
|
|
161
|
+
*) fail "GET /$BOARD/feed" "no event-stream header: $(printf %s "$headers" | tr '\r\n' ' ')" ;;
|
|
162
|
+
esac
|
|
163
|
+
|
|
164
|
+
printf '\nall green — %s, board %s\n' "$HOST" "$BOARD"
|
|
@@ -52,8 +52,26 @@ def token_in(header: str, path: str) -> str:
|
|
|
52
52
|
agent's client sends a header, a browser follows a link (`?token=`), and a
|
|
53
53
|
newcomer redeems an invite (`?invite=`). One extractor, so a door added
|
|
54
54
|
later — /feed, /git — cannot accidentally invent a second way in.
|
|
55
|
+
|
|
56
|
+
POST-MORTEM (shipped broken in 0.5.0, reported 2026-08-31): this used to be
|
|
57
|
+
`header.removeprefix("Bearer ").strip()`, and that one line made every
|
|
58
|
+
PUBLIC board unreadable from its own hosted page. The page's client sends
|
|
59
|
+
`Authorization: "Bearer " + token()` — with no token, the header reaches
|
|
60
|
+
the server as `Bearer` (the trailing space stripped in transit), the prefix
|
|
61
|
+
`"Bearer "` does not match, and the LITERAL STRING "Bearer" came back as
|
|
62
|
+
the token. `creds.check` then answered "unknown credential — run: taskops
|
|
63
|
+
join" to a reader who presented nothing at all. Anonymous reads worked only
|
|
64
|
+
when NO Authorization header was sent — which no browser client does. So:
|
|
65
|
+
scheme and value are parsed as separate whitespace-delimited fields, the
|
|
66
|
+
scheme case-insensitive per RFC 7235, and a bearer scheme with NO value is
|
|
67
|
+
NO credential presented — the query-string fallback and, behind it, the
|
|
68
|
+
anonymous gate still run. This is deliberately narrow: a header that
|
|
69
|
+
carries any actual value — wrong, expired, or under a scheme this server
|
|
70
|
+
never spoke — is a credential that WAS presented, and it refuses exactly as
|
|
71
|
+
it always has. "Unparseable" is not "anonymous".
|
|
55
72
|
"""
|
|
56
|
-
|
|
73
|
+
scheme, _, value = header.strip().partition(" ")
|
|
74
|
+
token = value.strip() if scheme.lower() == "bearer" else header.strip()
|
|
57
75
|
if token:
|
|
58
76
|
return token
|
|
59
77
|
for part in path.partition("?")[2].split("&"):
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""The request handler — one method per door, split out of `server.py` so the
|
|
2
2
|
router (the class) and the server's lifecycle (`BoardServer`, `serve`) each
|
|
3
|
-
have room. What each route MEANS is `server.py`'s docstring; this file is
|
|
4
|
-
how one request travels through it.
|
|
3
|
+
have room. What each route MEANS is `server.py`'s docstring; this file is
|
|
4
|
+
only how one request travels through it (`routes.py` reads the path).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -13,6 +13,8 @@ from http.server import BaseHTTPRequestHandler
|
|
|
13
13
|
from . import rpc, feed, admin, login, static, gitdoor
|
|
14
14
|
from .. import _clock
|
|
15
15
|
from .auth import Credential, token_in, anonymous
|
|
16
|
+
from .mounts import NAME
|
|
17
|
+
from .routes import split
|
|
16
18
|
from .._errors import BadRequest, TaskopsError
|
|
17
19
|
from .._version import __version__
|
|
18
20
|
|
|
@@ -67,7 +69,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
67
69
|
elif tail.startswith("git/"):
|
|
68
70
|
self._git(board, tail[4:])
|
|
69
71
|
elif tail.startswith("ui"):
|
|
70
|
-
self._static(board, tail[2:])
|
|
72
|
+
self._static(board, tail[2:]) # 0.5.0's address, kept — links were pasted
|
|
73
|
+
elif self.mounts.ui is None and NAME.match(board) and (not tail or static.asset(tail)):
|
|
74
|
+
# the page at the board's OWN address; assets are a CLOSED set
|
|
75
|
+
self._static(board, tail)
|
|
71
76
|
elif (page := static.at_root(self.mounts.ui, self.path)) is not None:
|
|
72
77
|
self._send(200, *page) # a WINDOW serves its page at the ROOT
|
|
73
78
|
else:
|
|
@@ -129,8 +134,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
129
134
|
return gitdoor.answer(repo, rest, self.path.partition("?")[2], mirrored=mirrored)
|
|
130
135
|
|
|
131
136
|
def _static(self, board: str, rest: str) -> None:
|
|
132
|
-
"""The
|
|
133
|
-
|
|
137
|
+
"""The page — at the board's own root since tk-32d2ba, and still at
|
|
138
|
+
/ui/, the 0.5.0 address (kept: links were pasted). A WINDOW serves
|
|
139
|
+
its bundle to whoever reaches the port, unchanged. A serve-mode HOST
|
|
140
|
+
serves the SAME packaged bundle for a board
|
|
134
141
|
whose owner declared a forge (`repos.backed` — the fact, never a clone),
|
|
135
142
|
behind the credential /rpc asks for: public board, anonymous READ;
|
|
136
143
|
private board, the join refusal. The 410 comes BEFORE the credential on
|
|
@@ -191,9 +198,3 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
191
198
|
|
|
192
199
|
def _fail(self, status: int, err: TaskopsError) -> None:
|
|
193
200
|
self._json(status, rpc.failure(err))
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def split(path: str) -> tuple[str, str]:
|
|
197
|
-
clean = path.partition("?")[0].strip("/")
|
|
198
|
-
board, _, tail = clean.partition("/")
|
|
199
|
-
return board, tail
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Where a request path splits into `(board, tail)` — ONE function, shared by
|
|
2
|
+
GET and POST so the two methods cannot disagree about what a path names. Split
|
|
3
|
+
out of `handler.py` at the seam that owns no HTTP at all (this module sees
|
|
4
|
+
strings, never sockets), because the handler sits on the ≤200-line budget
|
|
5
|
+
`tests/test_architecture.py` enforces.
|
|
6
|
+
|
|
7
|
+
`api/` is stripped HERE, once. Since the board's own address became the page
|
|
8
|
+
(tk-32d2ba), the machine doors — rpc, git, feed, invite/redeem — also answer
|
|
9
|
+
under `/<board>/api/…`, which is the spelling the page uses from now on.
|
|
10
|
+
Stripping in the split rather than per door means every door gets both
|
|
11
|
+
spellings and none can drift: `/<board>/api/rpc` IS `/<board>/rpc` — same
|
|
12
|
+
handler, same credential, same words. The 0.5.0 spellings stay, unprefixed,
|
|
13
|
+
because agents, the MCP client, `taskops ui`'s upstream forward and four
|
|
14
|
+
legacy production boards speak them today.
|
|
15
|
+
|
|
16
|
+
The prefix cannot shadow a page asset: the packaged bundle's filenames are a
|
|
17
|
+
CLOSED SET and none of them is named `api` (`static.asset` argues the same
|
|
18
|
+
fact from the router's other side). And the ROOT is untouched — `/login`,
|
|
19
|
+
`/rpc` and `/healthz` are one segment, so the strip, which lives in the TAIL,
|
|
20
|
+
never sees them; a board named `api` still answers at `/api/…` because the
|
|
21
|
+
strip runs after the board segment is taken.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def split(path: str) -> tuple[str, str]:
|
|
28
|
+
clean = path.partition("?")[0].strip("/")
|
|
29
|
+
board, _, tail = clean.partition("/")
|
|
30
|
+
if tail.startswith("api/"):
|
|
31
|
+
tail = tail[4:]
|
|
32
|
+
return board, tail
|
|
@@ -86,6 +86,25 @@ def resolve(root: Path, rest: str) -> Path | None:
|
|
|
86
86
|
return fallback if fallback.is_file() else None
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def asset(rest: str) -> bool:
|
|
90
|
+
"""Is this tail a file the packaged bundle actually ships?
|
|
91
|
+
|
|
92
|
+
Since tk-32d2ba the page lives at the board's OWN address, so its relative
|
|
93
|
+
links — `./style.css`, `./app.js` off `index.html` — arrive as
|
|
94
|
+
`/<board>/style.css` and the router must tell the page's tails from a
|
|
95
|
+
board's machine doors. It can, deterministically: the bundle's filenames
|
|
96
|
+
are a CLOSED SET — whatever `ui/build.mjs` wrote into the wheel, flat,
|
|
97
|
+
every suffix in `TYPES` — and none of them is `rpc`, `git`, `feed`, `ui`
|
|
98
|
+
or `api`, so an asset can never shadow a door and a door never shadows an
|
|
99
|
+
asset. Checked against the real files rather than a hardcoded list, so a
|
|
100
|
+
bundle that grows a font ships without a router edit; a nested or
|
|
101
|
+
dot-leading tail is refused before disk is asked, which also keeps `..`
|
|
102
|
+
out without a resolve."""
|
|
103
|
+
if not rest or "/" in rest or rest.startswith(".") or Path(rest).suffix not in TYPES:
|
|
104
|
+
return False
|
|
105
|
+
return (PACKAGED / rest).is_file()
|
|
106
|
+
|
|
107
|
+
|
|
89
108
|
def content_type(path: Path) -> str:
|
|
90
109
|
return TYPES.get(path.suffix, "application/octet-stream")
|
|
91
110
|
|