Skip to content

feat(tasks): add public read-only task sharing#2

Merged
emreycolakoglu merged 1 commit into
mainfrom
feat/public-task-sharing
Jul 16, 2026
Merged

feat(tasks): add public read-only task sharing#2
emreycolakoglu merged 1 commit into
mainfrom
feat/public-task-sharing

Conversation

@emreycolakoglu

Copy link
Copy Markdown
Owner

Click Make public in the task detail's ⋯ menu → the task is published and the public URL lands on your clipboard. Recipients open /public/TFG/123 with no account and see a read-only page.

The model: publication, not a secret link

Decided deliberately, and it shapes everything else: the URL is the task's real identity, not a token. So it's enumerable on purpose, and un-publishing means the page 404s from that moment on — it does not rotate the address. Anyone who held the link regains access if you re-publish. That's the accepted trade for readable, stable URLs.

Because there's no secret, two things carry the weight instead: a Public badge in the breadcrumb (so "which tasks are public?" is answerable by looking) and published/unpublished activity rows (so it's answerable after the fact).

What a recipient sees

taskNumber, title, description, status, priority, labels, comments, assignee display name.

Omitted by design — not oversight: activity (a who-did-what record of internal process), sub-tasks, parent, relations. Publishing one task must not disclose the titles of tasks nobody published.

Design notes worth reviewing

  • select, never include. TasksService.findOne returns assignee.email, assignee.role and the whole nested board object, so the public read is a hand-built projection rather than reuse. If someone switches it to include, that's the leak.
  • Publishing isn't a DTO field. update()'s activity diff uses truthy checks (if (dto.title && …)), so isPublic: false would be applied but never logged — the un-publish, the one action that must be auditable, would vanish. A DTO field would also let MCP's generic tasks_update publish a task as a side effect of a field write. Hence dedicated PUT/DELETE /api/tasks/:id/publish.
  • Bot sessions rejected. Bot tokens live 365 days and AuthGuard accepts them like any session, so an agent could otherwise publish over REST. Verified end-to-end that bots get 403 on publish but still read normally.
  • Editing is structurally impossible, not flag-gated. There is no unauthenticated write path — only a GET. Verified: anonymous PUT /tasks/:id, POST /comments and publish all 401.
  • The public route mounts above AuthProvider rather than being added to its hardcoded pathname.startsWith('/signup/') exemption. A provider that isn't above the page can't redirect it — no startsWith for a refactor to break, no /auth/status roundtrip, no redirect flash.
  • Separate fetch client. hooks/api.ts clears the token and redirects to /login on any 401. Routing an anonymous page through it would log a signed-in colleague out of their own session just for opening a public link.
  • 404, never 403, for a private task — a 403 would confirm it exists.
  • noindex + robots.txt. Enumeration takes intent; a search result takes none.

Verification

  • API: 296 passed (20 suites), incl. 12 new PublicService specs and 8 setPublic specs.
  • Web: 129 passed (13 files), incl. a new detail-breadcrumb-bar.test.tsx.
  • tsc --noEmit: 3 errors, unchanged from baseline on main (pre-existing, untouched).
  • End-to-end against a running server on a throwaway DB: publish → anonymous 200 → payload contains no email/passwordHash/activity/board → anonymous writes 401 → bot publish 403 → MCP tasks_update cannot flip visibility → unpublish → 404 → activity logged. Plus /public/TFG/1 serves the SPA with noindex present.

Also in here

AGENTS.md (which CLAUDE.md symlinks to) was wrong in four ways that cost real time: it claimed no authentication (there's a global APP_GUARD, sessions, bcrypt, invites), that events use EventEmitter2 (RxJS Subject), that a lists module exists (renamed to statuses), and that no component tests exist (they do). Documented the new feature, plus two traps: the web build is bare vite build and does not typecheck, and board identifiers must be exactly 3 uppercase letters.

Flagged, deliberately not fixed

  • Member is modeled with admin/member/viewer roles and nothing reads it. Any authenticated user can read and write every board. This share button is the app's first access-control boundary, sitting on top of no boundary at all. Deserves its own PR — it'd touch every controller.
  • Local dev DB drift: prisma migrate dev wants to reset because 20260701070630_rename_list_to_status was modified after being applied. Not caused here; the migration was generated via migrate diff to avoid wiping anyone's data.
  • PUT /api/tasks/reorder is dead — declared after @Put(':id') with the same segment count, so update() swallows it with id="reorder". Pre-existing; :id/publish is unaffected (extra segment).

🤖 Generated with Claude Code

Publish a task from the detail breadcrumb menu and get a public URL on the
clipboard in one click. Recipients see a read-only page with no session.

Model is publication, not a secret link: the URL is the task's real identity
(/public/TFG/123), so it is enumerable by design, and un-publishing 404s the
page rather than rotating the address.

- Task.isPublic (default false) + migration
- PUT/DELETE /api/tasks/:id/publish -> TasksService.setPublic, kept out of
  UpdateTaskDto so update()'s truthy-check activity diff can't silently drop
  the un-publish, and so MCP's generic tasks_update can't flip visibility as a
  side effect. Writes published/unpublished activity. Rejects bot sessions.
- GET /api/public/tasks/:identifier/:number -> PublicService, @public().
  Hand-built select (never include) so assignee.email/role and the nested
  board object cannot ride along. 404 for both "missing" and "private".
- Payload omits activity, sub-tasks, parent and relations by design:
  publishing one task must not disclose titles nobody published.
- Public route mounts above AuthProvider rather than being string-exempted
  from inside it, and fetches via a separate client — the shared one clears
  the token and redirects on 401, which would log a colleague out of their own
  session just for opening a public link.
- noindex meta + robots.txt.

Also corrects AGENTS.md, which claimed the app has no authentication (it has a
global APP_GUARD), that events use EventEmitter2 (RxJS Subject), that a `lists`
module exists (renamed to statuses), and that no component tests exist. Adds
the unenforced-Member-model gap and the vite-build-doesn't-typecheck trap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@emreycolakoglu
emreycolakoglu merged commit 89b5580 into main Jul 16, 2026
1 check passed
@emreycolakoglu
emreycolakoglu deleted the feat/public-task-sharing branch July 16, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant