* fix(playbooks): enforce draft gate server-side + expose status (BUG-2020)
pad_playbook run / POST /playbooks/{ref}/run now refuse a playbook whose
status isn't "active" with a structured playbook_not_active error. Adds
an allow_draft escape hatch across all surfaces: JSON body field, CLI
--allow-draft flag, MCP boolean param, and an "allow-draft" bareword in
raw_args (stripped before strict parsing). status is now echoed on both
the run and get responses.
Bumps ToolSurfaceVersion 0.9 -> 0.10 and updates the drift-guarded docs
(instructions.md, README.md) plus CLAUDE.md.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(playbooks): forward allow_draft on WebMCP + refresh mcp serve help
Codex review follow-up for BUG-2020:
- WebMCP dispatcher + api client now forward allow_draft so the browser
surface can use the draft-gate escape hatch the catalog advertises.
- `pad mcp serve --help` refreshed from the stale "v0.4 / eight tools"
text to the current v0.10 / nine-tool surface (incl. pad_library).
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(playbooks): type playbooks.get() with top-level status (BUG-2020)
Codex P3 follow-up: the get response now returns Item & { status }.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(server): surface dashboard sub-query failures instead of silent empty (BUG-2014)
buildDashboardResponse assembled several best-effort sections with
`if err == nil` / `if err != nil { continue }` and no logging, so a
failing sub-query rendered indistinguishably from a genuinely-empty
section — a completely silent degradation.
Add a `Degraded` bool + `DegradedSections []string` to DashboardResponse.
A new markDegraded helper logs each failure (slog.Error with workspace +
section) and records the affected section, so partial failures are both
diagnosable server-side and visible to the client without changing the
all-or-nothing contract for the queries whose failure genuinely
invalidates the whole dashboard (those still return an error). Wired into
active_plans, attention.stalled, attention.orphaned_tasks, recent_activity,
by_role, and starred_items. The has_agent_activity source fallback (which
has a valid default) logs a Warn but does not degrade.
Mirror the new fields in the TypeScript DashboardResponse type and add a
Go test asserting a failed sub-query flips Degraded, names the section,
keeps the endpoint at 200, and preserves the healthy sections.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(server): skip orphan detection when GetParentMap fails (BUG-2014 review)
A GetParentMap failure previously fell back to an empty parent map and
still iterated allTasks, flagging every visible non-done task as an
orphaned_task (false positives). Skip orphan detection entirely on that
failure — the section is already marked degraded.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* feat(web): show degraded-load banner on the dashboard (BUG-2014)
Consume the new DashboardResponse.degraded / degraded_sections signal on
the workspace dashboard page. When a best-effort sub-query fails
server-side, the affected sections could otherwise render as genuinely
empty; surface an amber "some data couldn't be loaded" banner (listing
the affected sections) so the partial-failure state is visible instead of
silent.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
PR #860 put the attach affordance on the slash menu + mobile toolbar, but
the slash menu doesn't surface on touch — so the reliable mobile entry
point is the block context menu (⠿ drag-handle → tap → Turn into /
Duplicate / Delete). This adds an "Attach file" INSERT action there,
giving mobile parity with the desktop /attach slash command.
- Gated on the AttachmentUpload extension being present (canAttach).
- Positioned below the turn-into divider so it stays visible for atom
blocks (where the turn-into section is hidden).
- attachmentImage/attachmentChip are inline atoms. blockAtPos returns the
CONTAINER for list items / blockquotes, so the handler descends to the
last non-code textblock to find a valid inline position (paragraph,
heading, list-item paragraph, blockquote paragraph). Atoms / code blocks
have no inline home, so a host paragraph is created after the block.
- Common path sets the editor selection at click time so ProseMirror maps
it forward across intervening edits (collab peers, in-flight uploads) —
no stored raw position to go stale. The rare paragraph path is deferred
to file-pick and clamped, so cancelling the picker changes nothing.
- Reuses the uploadAttachments command (same paste/drop pipeline); no
ProseMirror/Y.Doc node-spec change, so no collab schema bump.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
The editor's attachment upload was paste/drop-only, which is unreachable
on touch devices — so the mobile shells' file-chooser plumbing (TASK-2049)
had nothing to trigger. Expose the existing startUpload pipeline as a
Tiptap `uploadAttachments(files)` command and wire an attach button +
hidden file <input> into two surfaces: the mobile toolbar (📎 button) and
a `/attach` slash command (the desktop insertion affordance, same pattern
as tables / HTML / import-from-URL).
Reuses the paste/drop flow verbatim — images become attachmentImage,
other files become attachmentChip, with the same upload/onError options.
Pure UI change: no ProseMirror/Y.Doc node-spec change, so no collab
schema-version bump.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
PLAN-1694 Phase 1 specced "hide the bar when the on-screen keyboard is
up" but it shipped unimplemented — the fixed BottomNav stayed pinned
above the keyboard, eating vertical space during text entry.
Detect the keyboard from its own geometry: track visualViewport.height
against the tallest height seen (the keyboard-closed baseline) and flag
keyboardVisible when it shrinks >150px. This works on iOS Safari and
Android Chrome, unlike `innerHeight - visualViewport.height`, which stays
~0 on browsers that shrink innerHeight in lockstep with the visual
viewport. Baseline grows as the URL bar collapses on scroll and
re-captures on orientationchange so rotation/chrome don't false-trigger;
gated on isTouch.
BottomNav gates the <nav> on !keyboardVisible (docked sheets stay mounted
so QuickCapture survives raising the keyboard itself), and drops the
has-bottom-nav content-reflow padding with it to avoid a dead gap.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
The palette's local (MiniSearch) path indexes title/ref/tags/fields but
not the rich-text body, so a query that only matches an item's content
showed nothing unless the user knew the hover-only `body:` tooltip. On
sparse local hits, fire a debounced (250ms), current-workspace-scoped
server FTS query and render the extra, deduped hits under a "Matches in
content" section. Request-id token drops stale responses; the section
(and its `.results` container) collapses to the honest "No results"
state when it resolves empty. In all-workspaces mode the section is
labeled "current workspace" since the fetch is a single scoped request.
Also replaces the misleading empty-state tip ("...or any text") with an
honest, always-visible `body:` hint (the old hint was a hover tooltip,
invisible on mobile).
CLAUDE.md mandates exact-pinning the coordinated Tiptap packages so npm
cannot silently slide the shared Y.Doc/ProseMirror schema out of sync.
@tiptap/core (^3.22.5), @tiptap/y-tiptap (^3.0.3) and @tiptap/pm (^3.20.4)
were still floating on caret ranges; only @tiptap/extension-collaboration
was exact.
- Exact-pin @tiptap/core, @tiptap/y-tiptap and @tiptap/pm to their
currently-resolved versions (3.22.5 / 3.0.3 / 3.22.5); refresh lockfile.
- Add web/scripts/check-tiptap-pins.mjs + check:tiptap-pins npm script:
fails if any coordinated package loses its exact pin in package.json or
resolves to more than one version in the lockfile.
- Wire the guard into the CI web job.
The Danger Zone delete copy (TASK-1976) only mentioned the post-delete
Undo prompt because the persistent recovery surfaces weren't merged yet.
Both are now live: the workspace switcher's "Recently deleted" section
and the /console/deleted-workspaces page. Name all three 30-day recovery
paths so users know they can restore beyond the Undo toast.
Copy-only; no logic change. Keeps the Delete naming, typed-slug confirm,
and owner-only gating.
Closes TASK-1977
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
After deleting a workspace, the settings page now shows the success
toast with an inline Undo action. Undo calls api.workspaces.restore on
the deleted slug (captured before the redirect so it works post-
navigation) and navigates back into the restored workspace. The toast
uses a longer duration and the global toast store survives the
post-delete redirect to /console.
The Danger Zone delete copy now states the workspace stays recoverable
for the full 30-day window (with the Undo prompt right after deleting),
making the soft-delete window honest. Copy intentionally avoids naming
the persistent recovery surfaces (switcher restore = TASK-1974, console
Deleted-workspaces page = TASK-1975) since those land in sibling tasks;
those PRs can add the "restore from the switcher" pointer once merged.
Typed-slug confirm + owner-only gating (TASK-1967) unchanged.
Closes TASK-1976
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* feat(web): add "Recently deleted" section to workspace switcher
Adds a collapsible "Recently deleted" section below the active-workspaces
list in WorkspaceSwitcher, populated from api.workspaces.listDeleted() and
loaded each time the switcher opens. Each row shows the workspace name, a
subtle "N days left", and an inline Restore button that calls
api.workspaces.restore() then refreshes both the deleted list and the
active workspaces list so the restored workspace reappears; a success
toast confirms. The whole section is hidden when there are no deleted
workspaces, and fetch failures are swallowed quietly so the switcher never
breaks. Renders in both the desktop dropdown and the mobile BottomSheet.
Closes TASK-1974
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(web): decouple restore success toast from list refresh
A failing workspaceStore.loadAll() after a successful restore no longer
shows a misleading "Couldn't restore" toast. The restore API call now has
its own catch; the post-restore refresh is guarded separately so a reload
failure stays silent (the restore already succeeded). Addresses Codex P2.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(web): sequence deleted-list fetches to avoid stale overwrite
Adds a monotonic request token to loadDeleted() (mirroring workspaceStore's
membershipSeq) so an older open-triggered listDeleted() response can no
longer clobber the fresher post-restore refresh and re-surface a
just-restored workspace with a live Restore button. Addresses Codex P2.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Add a full-page table of the caller's soft-deleted workspaces
(name, deleted date, days-left, Restore per row) backed by
api.workspaces.listDeleted / restore. Restore removes the row and
toasts success; loading/error/empty states included. Wire a
"Deleted workspaces" link into the console layout nav.
Closes TASK-1975
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Wire the web TypeScript API client to the TASK-1970 restore endpoints:
- workspaces.restore(slug) -> POST /workspaces/{slug}/restore, returns
the restored Workspace.
- workspaces.listDeleted() -> GET /workspaces/deleted, returns
DeletedWorkspace[] (Workspace + purge_at + days_left).
Uses the shared request() helper and the existing DeletedWorkspace type
(no redefinition). The restore UI that consumes these lands in
TASK-1974/1975/1976.
Closes TASK-1971
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Foundation for PLAN-1969 (user-recoverable workspace soft-delete). A
workspace delete only stamps workspaces.deleted_at; items/collections/
members are untouched, hidden transitively. Restore clears deleted_at so
everything re-surfaces intact.
Store (internal/store/workspaces.go):
- RestoreWorkspace(slug): UPDATE ... SET deleted_at = NULL WHERE slug=?
AND deleted_at IS NOT NULL. Returns sql.ErrNoRows (-> 404) when no
soft-deleted row matched (already live or purged).
- ListDeletedWorkspaces(userID, cutoff): owner-scoped, deleted_at within
the window, ordered deleted_at DESC. Account-deleted workspaces have no
live owner, so they never leak.
- GetDeletedWorkspaceBySlug(slug): resolves a soft-deleted row (the normal
resolvers filter deleted_at IS NULL) so the handler can tell 403 from 404.
- Dual-dialect via s.q/s.dialect; no migration (deleted_at already exists).
Handlers (internal/server/handlers_workspaces.go):
- POST /api/v1/workspaces/{slug}/restore: owner-only; 404 not-restorable,
403 non-owner, 200 + restored workspace; logs a "restored" activity.
- GET /api/v1/workspaces/deleted: owner-scoped list with per-entry
purge_at + days_left, both derived from workspacePurgeRetention so
restore and the purge sweeper share ONE 30-day window (no drift).
- Both routed outside the /{slug} RequireWorkspaceAccess subrouter (which
resolves only live workspaces); restore enforces owner authz inline.
CLI client (internal/cli/client.go): RestoreWorkspace + ListDeletedWorkspaces.
TS type (web/src/lib/types/index.ts): Workspace.deleted_at + DeletedWorkspace.
Tests: store (resurface-intact; double-restore/live -> ErrNoRows; window
boundary 29d IN / 31d OUT + owner-scoping) and handler (owner-only 403,
404 live/unknown, 200 restore, owner-scoped deleted-list). Green on
SQLite and Postgres (make test-pg); golangci-lint clean.
Closes TASK-1970
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
The workspace Danger Zone said "Archive" and "The data is preserved but
no longer accessible." That is false: the action soft-deletes
(workspaces.deleted_at) and TASK-1966's sweeper hard-purges any
deleted_at workspace after 30 days.
Rename the action to "Delete" across the heading, buttons, in-flight
label, and confirm warning; rewrite the body copy to state it hides the
workspace + all contents immediately and permanently deletes it 30 days
later; update the success/failure toasts. Typed-slug confirm + owner-only
gating unchanged; copy/toasts only.
A workspace restore path (within the 30-day window) is planned as a
follow-up, so the copy states the 30-day finality without claiming the
workspace is unrecoverable.
Closes TASK-1967. Follows TASK-1966 (the purge sweeper).
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Backend contract tests (internal/server):
- delete-account success-body: pin the exact {ok:true} envelope the UI
consumes (the cascade/skip tests asserted only status 200).
- export happy-path: decode the artifact and assert the exact
`attachment; filename="pad-export.json"` header, application/json type,
and the top-level {user, workspaces} shape with inline collections/items
(fills the TASK-508 gap; complements the BUG-1945 gate smoke test).
- TOTP paths (enabled+valid/missing/invalid, non-TOTP) were already added
in TASK-1958 — confirmed, not duplicated.
Web e2e (web/e2e, Playwright): settings Danger Zone —
- export download (filename + success line),
- delete password branch (real register→login→delete; admin user search
confirms the row is gone),
- delete cloud OAuth-only typed-confirm branch (session/me flags patched;
delete transport stubbed since a self-host server requires a password),
- post-delete redirect to /login.
Delete specs run desktop-only (viewport-agnostic; avoids doubling
IP-rate-limited /auth/login + /auth/register hits that flaked the suite
under parallel load).
No product code changed.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Adds a two-step delete-account flow to the console settings Danger Zone,
cloning the inline TOTP-disable reveal pattern. Branches on auth method:
password re-entry for email/password + self-host accounts, typed
confirmation (email or DELETE) for cloud OAuth-only accounts, plus a
required TOTP code when 2FA is enabled (server re-verifies).
Shows the three account-deletion warnings (irreversible; cancels paid
subscription with no refund; shared workspaces are deleted and members
lose access), nudges users to export their data first, and renders the
server's billing_cancel_failed / partial_delete messages verbatim. On
success it clears the auth store and hard-redirects to /login with no
further API calls. Focus-on-reveal, Escape-to-cancel, aria-describedby.
Closes TASK-1962
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Add a new ungated Danger Zone card as the last section of the account
settings page, with an "Export my data" button wired to
exportAndDownloadAccountData(). Export works self-host too, so the
section is not gated on cloudMode.
The button holds a persistent disabled + in-flight ("Exporting...")
state for the whole request (server has a 60s deadline) so the user is
never left without feedback. Inline error rendering (.error) surfaces
the restricted-owner 403 (PadApiError.message) and network/timeout
failures; a .success line confirms the download.
Reuses .danger-btn/.error/.success; the section header + border are red
per the existing danger palette (#ef4444). Follows the page's
per-section $state trio convention (exportSaving/exportMsg/exportError).
Closes TASK-1961
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
The delete-account UI must branch between a password prompt (self-host or
any user with a password) and a confirm-only flow (OAuth-only users with
no password). The client had no signal for this: oauth_providers is not a
valid proxy since a user can have both a password and linked OAuth.
Add "password_set": user.HasPassword() to the /auth/me response map and
password_set?: boolean to the TS User interface. A handler test asserts
the field for both a password user (true, via bootstrap) and an
OAuth-only user (false, via CreateOAuthUser).
Closes TASK-1957.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Wire the existing account delete/export endpoints into the web API
client (TASK-1960). Neither was callable from the web today.
- api.auth.deleteAccount(opts) — POSTs /auth/delete-account through the
shared request() helper so X-CSRF-Token + credentials are attached
(a hand-rolled fetch would 403). Optional password/confirm/totp_code.
- api.auth.exportAccountData() — modeled on exportItemArtifact: bare
credentialed fetch, 401 -> /login, non-OK -> PadApiError (surfaces the
restricted-owner 403, BUG-1945), Content-Disposition filename with a
pad-account-export.json fallback. Returns { filename, text }.
- exportAndDownloadAccountData() util pipes the bytes into
downloadTextFile as application/json.
Closes TASK-1960
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Reorder ConnectWorkspaceModal so MCP setup is the default and first tab —
most users landing here have never connected an agent, so the OAuth "fresh
agent" path is their real first step. CLI is second; the claim code moves to
a third "Connect code" tab, reframed as a scoped-grant add-on rather than the
(misleading) "recommended" default it was.
Close the zero-grants dead end: add Store.HasActiveConnectionForUser and
surface has_any_connection on the claim-code endpoint, so a user who opens the
Connect-code tab with no connected agent gets steered to set one up first
instead of a live-looking but unredeemable code. Hide the MCP + code tabs on
self-host deployments without a public MCP URL (both depend on the remote
OAuth server), leaving CLI as the sole, default path there.
Verified: go build ./..., go test ./internal/server/ ./internal/store/,
web npm run check (0 errors), and a Codex review all pass clean.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(web): dedupe collab flush in editor-markdown space (BUG-1941)
A no-edit view's flush re-serialized the editor markdown through the
flush-time wiki-link index before comparing to the loaded baseline;
when that index differed from the seed-time index (or the stored link
was already non-canonical), the dedupe missed and a spurious PATCH
bumped updated_at, floating the card in Manual-sorted boards
(regression of BUG-1899). Capture the exact markdown seeded into the
Y.Doc and short-circuit the flush in editor-markdown space, before
markdownToWikiLinks ever runs, scoped to sessions with no prior flush
so revert-after-edit still saves. Also give the Manual sort comparator
a deterministic tiebreak so a stray updated_at bump can't reorder
untouched cards.
* fix(web): eagerly compute collab-flush seed for every tab (BUG-1941 follow-up)
Only the multi-tab-election winner captured a seedMd via the lazy-seed
effect, leaving a second tab on the same item — or simply reopening an
item that already has collab history — without editor-space dedupe
coverage, falling back to the pre-fix spurious-PATCH behavior. Compute
a best-effort seedMd for every tab at context-creation time by
projecting the same item.content baseline the storage-space dedupe
already trusts through the identical wiki-link transform; a mismatch
here can only cause a missed dedupe (falls through to the existing
compare), never a false one, so it's safe even when the projection
goes stale. The winning tab's precise lazy-seed capture still
overwrites this value unchanged.
Cloud self-registration mails a link to /verify-email/<token> but no
SvelteKit route existed there, so every self-registered user 404'd and
could never verify (blocking mutate/invite under DR-1 model b). Add the
route to auto-consume the token on mount, plus a client method for the
existing POST /auth/verify-email consume endpoint (distinct from the
admin force-verify). Success refreshes the session so emailVerified
flips and redirects to /console; failure offers resend (or sign-in).
Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
Wave 5 of PLAN-1933 (DR-1 model b) — surfaces the unverified-email state
in the web UI and makes it actionable.
- AuthSession user type gains `email_verified` (owns the session user-type
change); register response user type gains it too.
- authStore.emailVerified getter, default TRUE (mirrors `emailConfigured ??
true`) — a missing field or a self-host instance must never show the
banner.
- VerifyEmailBanner rendered in the workspace layout above ConnectBanner,
shown only when `cloudMode && user && !emailVerified`, with a Resend
button hitting POST /auth/resend-verification and a "sent" confirmation
(enumeration-safe, always 200).
- api.auth.resendVerification client method.
- /register shows a "check your email to verify your account" state after a
cloud self-serve signup returns an unverified user, instead of navigating
in and implying full access; includes resend + continue actions.
Gates: make check (lint + go test + govulncheck + web-check) green;
cd web && npm run check → 0 errors.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Wave 4 of PLAN-1933 (DR-7). Adds a web-console-only admin override to
force-verify a locked-out unverified account. No CLI, no MCP (matches
the no-auth-mutation-on-MCP rule).
Server:
- POST /api/v1/admin/users/{userID}/verify-email — admin-only, mirrors
handleAdminEnableUser. Reuses the existing SetUserEmailVerified store
method (added in Wave 3b) and audits with the distinct
ActionEmailVerifiedByAdmin action (separate from the self-serve
ActionEmailVerified — a force-verify is an operator security action).
Idempotent (already-verified returns 200 no-op).
- Surface email_verified_at in the admin list + get-user JSON so the
console knows verified state (Wave 1 only added the store-level scan).
Web:
- adminVerifyEmail client method (api.admin.verifyEmail) confined to the
admin section of client.ts.
- "Mark email verified" action in the admin user panel (UserSettingsForm),
shown only when the target user is unverified.
- email_verified_at added to the AdminUser type.
Tests: admin force-verifies an unverified user (flips email_verified_at +
audits ActionEmailVerifiedByAdmin, not the self-serve action) and the
now-verified session is unblocked; non-admin -> 403 with no side-effect.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
On Pad Cloud, an invitee who signed up via Google/GitHub from /join was
never added to the inviting workspace: /join rendered no OAuth buttons,
and the OAuth round trip dropped the pending invitation.
Surface the shared AuthOAuthButtons on /join (cloud-mode only, matching
/login and /register) and thread the invite code through the provider
link's ?redirect= as /join/<code>. OAuth returns via a full-page nav to
that URL, where onMount's existing session probe sees `authenticated`
and calls acceptInvitation(code) to finish the join. The redirect stays
same-origin (validateRedirect); the accept remains email-bound server
-side (403 invitation_email_mismatch on a mismatched OAuth email), whose
message already surfaces in the page's error state.
Single-repo per PLAN-1933 DR-8 (option A) — no pad-cloud change.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
On Pad Cloud the /join/[code] page never showed the invited email, so a
mistyped address hit a confusing 403 invitation_email_mismatch. Add a
non-consuming, public, always-200, rate-limited preview endpoint and wire
the join page to prefill the invited email read-only.
- GET /api/v1/invitations/{code}/preview returns {found,email,workspace_name,
has_account}. Reuses store.GetInvitationByCode (never accepts/consumes the
invite). Invalid/expired/missing codes and dangling-workspace codes all
return 200 {found:false} — no 404 status signal (enumeration safety). A
genuine DB fault still 500s (code-independent, leaks nothing).
- Public/pre-auth: added to isPublicAPIPath (matches only the trailing
/preview segment, so /accept stays auth-gated).
- Dedicated per-IP rate limiter (20/min, burst 20) wired into the RateLimit
switch so the endpoint can't be used to enumerate invite codes.
- TS client: api.members.previewInvitation + InvitationPreview type.
- /join page calls preview on mount, prefills + locks the invited email, and
defaults register-vs-login by has_account. Keeps the mode-switch affordance
and BUG-1930's register default when preview is unavailable.
- Tests: non-consumption, has_account, always-200 on unknown code, rate limit.
Composes with BUG-1930 (register default). Wave 0 of PLAN-1933 / IDEA-1927 §B5.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
A never-registered invitee previously saw the auth form defaulted to
login, tried to sign into a nonexistent account, and dead-ended. The
register path already exists and auto-accepts the invitation via the
invitation code, but was hidden behind a "Create one" toggle. Default
to register instead; the "already have an account? sign in" switch
still works both directions.
* fix(web): don't mask/redirect on 401 from auth-form submissions (BUG-1929)
The client's global 401 interceptor hard-redirected to /login and
threw a hardcoded "Authentication required" message for every 401,
including bad-credentials responses from login, register, and 2FA
verification — masking the server's real error and stranding invitees
mid-join. Auth-form 401s now surface the server's message inline
instead; session-expiry 401s elsewhere are unchanged except that the
redirect now preserves a ?redirect= return-to path.
* test(web): pin hostile ?redirect= values as rejected (BUG-1929 follow-up)
Codex R2 found no live browser-exploitable bypass in validateRedirect,
but two of the five hostile forms it checked (percent-encoded slash/
backslash, e.g. /%2Fhost) passed through unrejected today — not
exploitable via normal navigation, but a validator gap nonetheless.
Reject percent-encoded slash/backslash defensively and add unit pins
for all five hostile shapes so a future regression here can't be silent.
* feat(server): add REST endpoints for project next/standup/changelog
Adds GET /workspaces/{ws}/next, /standup, /changelog — session-authed
reads mirroring `pad project next|standup|changelog --format json`,
reusing buildDashboardResponse + store.ListItems so the browser
WebMCP surface stops returning "not available" for these catalog
actions (TASK-1894). Cross-references the MCP HTTP transport's
existing dispatchProjectNext/Standup/Changelog (dispatch_http_slice4.go)
with KEEP IN SYNC comments at both sites, since this is now a third
reproduction of the same reshaping contract pending a follow-up
consolidation.
* feat(web): wire next/standup/changelog into WebMCP dispatch + api client
Adds client.ts next()/standup()/changelog() methods and replaces the
three "not available in the browser" dispatch.ts stubs with real
handlers now that the backend endpoints exist (TASK-1894). Extracts
DashboardSuggestion as a shared type and adds StandupResponse /
ChangelogResponse types mirroring the Go response shapes.
* fix(server): make projectIntelVisibility bearer-aware (TASK-1894 codex R1)
standup/changelog's own item-list scoping used visibleCollectionIDs, which
has no bearer gate: a platform admin authenticated via a bearer token
(PAT/CLI/OAuth) who is only a restricted member of a workspace got the
unrestricted admin view instead of being scoped to their real membership.
Adds bearerAwareVisibleCollectionIDs, mirroring reportVisibleCollections'
existing BUG-1616/1617 gate, and switches projectIntelVisibility onto it
while preserving its item-level grant handling (which reportVisibleCollections
deliberately drops for aggregate reports).
buildDashboardResponse (and therefore /next, and standup's blockers/
suggested_next sections) is intentionally left ungated in this change —
gating it would break next's parity with dashboard.suggested_next and
diverge it from the CLI and MCP siblings. The resulting asymmetry is
documented inline pending a follow-up fix to buildDashboardResponse itself.
* docs(server): reference BUG-1917 in projectIntelVisibility comments
Replaces the textual placeholder ("the visibleCollectionIDs bearer-gate
bug filed from TASK-1894 review") with the actual bug number now that
it's been filed. Comment-only change, no behavior difference.
* fix(web): harden WebMCP scalar param readers against malformed types (TASK-1895)
str/num/bool now throw a precise tool error on a present-but-wrong-typed
value (e.g. a number where a string is expected) instead of silently
returning undefined, closing the scalar-validation residual from
TASK-1893's array hardening (strArray). Absent (undefined/null) and
empty-string params remain non-erroring, preserving existing optional-arg
semantics.
* fix(web): guard the action read inside dispatch()'s try/catch (TASK-1895 R2)
The action lookup (str(args, 'action')) sat outside the try/catch, so a
malformed action (e.g. action: 1) made str() throw unhandled and the
dispatch() promise rejected instead of resolving to an error envelope,
breaking register.ts's assumption that dispatch() always resolves.
Widen the try/catch to cover the action read through handler execution.
* fix(server): reserve parent/plan schema field keys (TASK-1912)
A collection schema field keyed exactly "parent" or "plan" makes the
parent-link extraction sites in handlers_items.go silently skip
fields-JSON extraction, disabling subtask linking with no error
anywhere. Reject newly-added occurrences of these keys on collection
create/update (grandfathering keys already present in a prior schema),
and add them to the web's reserved-key list so authors are steered
away before hitting the 400.
* fix(server): reject empty-string schema on collection PATCH (TASK-1912)
Codex round 2: handleUpdateCollection's validation guard was skipped
whenever input.Schema was a non-nil pointer to "", so a PATCH with
{"schema": ""} stored the empty string verbatim and every later
item-create against that collection 500'd instead of the mutation
being rejected up front. Drop the empty-string carve-out so "" flows
into json.Unmarshal, fails, and returns the existing 400 "Invalid
schema JSON". Omitting the schema field entirely (nil) is unaffected.
* feat(web): add copy-ID icon to item cards (IDEA-1904)
A hover-revealed copy button sits just right of the item ref on every
ItemCard (collection List/Board views + dashboard/starred/tags/share).
Reuses the existing copyToClipboard util and the item detail page's
clipboard→checkmark icon set; click calls preventDefault + stopPropagation
so it copies the issue ID without opening the card's <a> link.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(web): a11y for copy-ID button — SR announce + touch reveal (IDEA-1904)
Adversarial review flagged two real gaps in the copy-ID affordance:
- Copy success was visual-only; add a visually-hidden aria-live region
and a dynamic aria-label so screen readers hear "Copied IDEA-1904".
- The hover-only reveal (opacity:0) left it invisible on touch devices;
add an @media (hover: none) resting opacity like the star button, and
bump the tap target 18px -> 22px.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
`pad init` connects an agent (installs the skill, stores credentials) and
creates a workspace, but the web UI still showed the "connect an agent"
banner and onboarding launchpad. The only signal for "agent connected" was
has_agent_activity — an item existing with source cli/mcp — and a fresh
pad-init workspace has zero items, so the UI nagged to connect an agent the
user already had.
Give the server a truthful signal: a workspace created through an agent
surface already has an agent wired up before it creates its first item. Add
a `source` column to workspaces (web/cli/mcp), attributed authoritatively
server-side from the request auth shape (actorFromRequest) — never from the
request body, so a web client can't spoof "cli" to self-suppress the
prompts. The dashboard ORs source in (cli,mcp) into has_agent_activity when
the cheap item check comes up empty.
- migrations 069 (sqlite) / 047 (postgres): workspaces.source NOT NULL
DEFAULT '' (legacy rows stay "unknown", never treated as agent-created)
- models.Workspace.Source + WorkspaceCreate.Source (json:"-", server-set)
- thread source through the CreateWorkspace INSERT + all 7 workspace scan
sites (workspaces.go, workspace_members.go)
- handleCreateWorkspace derives source from actorFromRequest
- OnboardingLaunchpad step 1 collapses to "Agent connected" when the agent
is already wired up, shifting emphasis to "tell it to set up"
Web modal and cloud-signup auto-create flows are unchanged and still
correctly prompt to connect (source web / empty).
Tests: store source round-trip across reads; dashboard reports
agent-connected for a cli-created workspace with zero items; web-created
stays not-connected until an agent item exists; a web body-spoofed source
is ignored.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(admin): don't persist masked Maileroo API key on email settings save (BUG-1890)
The admin settings "Save Email Settings" button PATCHed the whole
platformSettings object. GET /admin/settings returns the Maileroo key
masked (abcd...wxyz for >8 chars, **** otherwise), so saving without
re-typing the key persisted the mask over the real key — silently
breaking email until re-entered.
Two layers:
- Client (+page.svelte): track whether the API-key field was edited
(apiKeyEdited flag) and scope the PATCH to the email fields this form
owns (mirrors the TASK-1889 Integrations save). The key is included
only when the admin actually edited it; an untouched save preserves
the stored key, and clearing the field still sends "" to disable.
- Server (handlers_admin.go): extract maskAPIKey() as the single source
of truth for the mask format and skip persisting maileroo_api_key when
the incoming non-empty value equals the mask of the currently-stored
key. Best-effort backstop for non-web/old clients; the client fix is
authoritative.
Tests (handlers_admin_settings_test.go): maskAPIKey unit cases, the
masked-key-not-persisted regression (both long and **** short masks),
real-key-update-wins, and empty-key-clears.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(admin): clear Maileroo key when disabling email provider (BUG-1890)
Codex review of the scoped email-save payload found a regression: when
an admin selects Provider "None" without touching the key field, the
scoped payload omitted maileroo_api_key, leaving the stored key. Because
reconfigureEmail keys email enablement off the presence of the API key
and ignores email_provider, "None" no longer disabled email.
Send an explicit empty key whenever the provider isn't Maileroo, so
disabling actually turns email off. The masked-key guard still applies
when the provider is Maileroo and the key was left untouched.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(server): tear down live email sender when platform key is cleared (BUG-1890)
Codex review: clearing the Maileroo key (e.g. disabling via provider
"None") wrote the empty key to the DB, but reconfigureEmail's empty-key
branch returned early without clearing the in-memory s.email sender —
so the running process kept sending mail until restart, contradicting
the UI's "disabled" state.
Track whether email was wired from env vars (emailEnvConfigured, set in
SetEmailSender). When platform settings carry no key, reconfigureEmail
now tears down the live sender (s.email = nil, emailAPIKey = "") unless
env config exists — env is the deployment baseline the admin UI doesn't
disable. Tests pin both the teardown and the env-preserved paths.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Add adjacent-column moves to the per-card kebab: left/right set the item's
group field to the neighbouring column value (left->right order) and land the
card at the top of the destination lane, reusing the drag status-change +
sort_order commit path (extracted commitColumnMove) with optimistic
source-lane removal so the card never double-renders. Left/right are a
separate optional onMove callback wired only by BoardView, leaving the vertical
onReorder type (and List/Table/Child hosts) untouched.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Activity rows (the dedicated Activity page and the dashboard's Recent
Activity list) showed only the item title, never the issue ID. Add the
ref (e.g. BUG-1748) as a leading monospace badge on both surfaces.
The ref rides on the per-row item lookup that already runs to populate
the title, so there are no new DB queries — enrichActivities and the
dashboard recent-activity builder now also copy item.Ref after
ComputeRef(). New item_ref field on models.Activity, DashboardActivity,
and the TS Activity / recent_activity types.
The Activity page now renders field changes as structured pills
("status: open → fixing") instead of a raw string, via a new shared
parseFieldChanges util that also replaces the private copy in
TimelineActivityCard.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
CI on main has been red since 2026-06-25 on two dependency-audit gates,
not on tests or code:
- govulncheck: golang.org/x/image@v0.41.0 carries two TIFF-decoder CVEs
(GO-2026-5066 out-of-bounds strip offset panic, GO-2026-5062 unbounded
tile sizes) reached via internal/attachments/processor_purego.go ->
image.Decode -> tiff.Decode. Both fixed in v0.43.0. The go mod tidy that
follows pulls the usual transitive x/{text,mod,sync,tools} bumps.
- npm audit (--audit-level=high --omit=dev): linkify-it@5.0.0 has a
high-severity quadratic-complexity ReDoS (GHSA-22p9-wv53-3rq4),
transitive via tiptap-markdown -> markdown-it -> linkify-it. Pinned to
^5.0.1 via an overrides entry (5.0.1 satisfies markdown-it's ^5.0.0).
Verified locally: govulncheck clean, go vet/build/test green, npm audit
high gate exits 0, web build + svelte-check green.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Detail pages loaded the full content-bearing /items (~4.7MB) just to resolve
[[wiki-links]], stalling/timing out the page; list pages were fine because they
use the local-first localIndex read model. Move the detail page + editor [[ picker
onto localIndex (getAll accessor; zero extra fetch on warm nav). Harden SQLite:
bound the connection pool + periodic wal_checkpoint(TRUNCATE). Codex review clean
(P1 collab-flush ws, P2 inline-create ws — both fixed).
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST