feat(tasks): add public read-only task sharing#2
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/123with 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/unpublishedactivity 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, neverinclude.TasksService.findOnereturnsassignee.email,assignee.roleand the whole nested board object, so the public read is a hand-built projection rather than reuse. If someone switches it toinclude, that's the leak.update()'s activity diff uses truthy checks (if (dto.title && …)), soisPublic: falsewould 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 generictasks_updatepublish a task as a side effect of a field write. Hence dedicatedPUT/DELETE /api/tasks/:id/publish.AuthGuardaccepts 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.GET. Verified: anonymousPUT /tasks/:id,POST /commentsand publish all 401.AuthProviderrather than being added to its hardcodedpathname.startsWith('/signup/')exemption. A provider that isn't above the page can't redirect it — nostartsWithfor a refactor to break, no/auth/statusroundtrip, no redirect flash.hooks/api.tsclears the token and redirects to/loginon 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.noindex+robots.txt. Enumeration takes intent; a search result takes none.Verification
PublicServicespecs and 8setPublicspecs.detail-breadcrumb-bar.test.tsx.tsc --noEmit: 3 errors, unchanged from baseline onmain(pre-existing, untouched).tasks_updatecannot flip visibility → unpublish → 404 → activity logged. Plus/public/TFG/1serves the SPA withnoindexpresent.Also in here
AGENTS.md(whichCLAUDE.mdsymlinks to) was wrong in four ways that cost real time: it claimed no authentication (there's a globalAPP_GUARD, sessions, bcrypt, invites), that events use EventEmitter2 (RxJSSubject), that alistsmodule exists (renamed tostatuses), and that no component tests exist (they do). Documented the new feature, plus two traps: the webbuildis barevite buildand does not typecheck, and board identifiers must be exactly 3 uppercase letters.Flagged, deliberately not fixed
Memberis 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.prisma migrate devwants to reset because20260701070630_rename_list_to_statuswas modified after being applied. Not caused here; the migration was generated viamigrate diffto avoid wiping anyone's data.PUT /api/tasks/reorderis dead — declared after@Put(':id')with the same segment count, soupdate()swallows it withid="reorder". Pre-existing;:id/publishis unaffected (extra segment).🤖 Generated with Claude Code