Breaking wire-format change: dual-read (V1+V2) / single-write (V2) so
existing user secrets, stored messages, and inter-tenant key keep
working. Old clients and relays lose cross-version chat until both
sides are upgraded.
Relay
- Switch clientEncrypt/masterEncrypt to AES-256-GCM with HKDF-derived
per-ciphertext salts; keep CBC-V1 fallback on decrypt only.
- On-bootstrap migration re-envelopes tenant secret_encrypted and the
inter-tenant key to GCM-V2 (idempotent).
- CORS: stop reflecting arbitrary Origin; allow own origin + optional
RELAY_CORS_ORIGINS env allowlist; drop Access-Control-Allow-Credentials.
- Generic error messages replace "no such user: <name>" enumeration.
- Exception messages no longer echoed back; logged server-side.
- Collab-lock max hold capped at 10min so a member can't grief-refresh
forever.
- Attachment filenames validated against hex.<ext> pattern.
- Passwords min length 10; /api/account/password rate-limited.
App / Electron client
- Shared hyprcrdRelayEncrypt/Decrypt helpers (GCM-V2 write, V1 read).
- Chat + collab-buckets plugins delegate to the shared helpers.
- Poll + push + attachments migrated to the dual-format path.
- /api/relay/settings GET returns has_key boolean; secret never echoed.
- Setup endpoint now CSRF-checked.
- SSRF: CURLOPT_PROTOCOLS restricted to HTTP(S) everywhere; bookmarks
favicon fetch also blocks private/loopback/link-local.
- Login / password-change rate limiter moved to DB-backed IP keying.
- Passwords min length 10 throughout.
- Zip import enforces per-file (25MB) and total (500MB) caps, extension
allowlist, no-overwrite.
- Verbose relay-push debug gated behind APP_DEBUG env.
Version bumped to 2.0.0 (protocol-breaking).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Relay:
- Inbox/shared/sent split into own view-inbox (nav button + poll).
Compose is now single-column, centered.
- Header logo links to compose. Inbox badge stays fresh on all
views via a quiet loadInbox on every view switch.
- Mobile menu: full-height sheet below header, larger buttons
(15px font, 14px padding), full width.
- Login form wrapped in <form> with name attributes + for/id on
labels so password managers can autofill.
- Admin user list shows "last seen <relative time>" per user.
last_seen_at column on tenants, updated on every authenticated
request (session + client API).
- Cache-busting on style.css + app.js via filemtime.
- View persistence in localStorage (survives reload).
- Shared-bucket cards show title only (preview line removed).
Favicon:
- favicon.svg added to project root + copied to public/, relay/,
website/. Linked via <link rel="icon" type="image/svg+xml"> in
all three <head> sections.
Settings:
- New toggle: "Show shopping list items in task collector"
(default off). collectTasks skips #einkauf/#shopping cards when
disabled.
Fixes:
- card-spinner gets display:inline-block so it rotates outside
flex containers (shopping save spinner).
- Tim tab test opens the plugin before checking active state.
- Compose view centered with margin:auto on desktop.
Bumps to 1.9.29.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The old single-list clearRemote wrote a dummy row with card_uid
'__inactive__' instead of deleting. After the multi-list migration
that row still exists alongside real lists, causing every list to
appear twice on the phone — once as "all done" (the empty sentinel)
and once with actual items. Filter it out in shoppingLoadAll and
delete it on bootstrap so it doesn't linger.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shopping lists:
- Multiple lists per tenant: picker uses checkboxes instead of
radios. Relay schema migrated to composite PK (tenant_id,
card_uid) with a position column for display ordering.
- Picker rows are drag-and-drop reorderable (grip handle +
accent drop indicator). Order persists in localStorage and is
pushed to the relay as position integers — phone renders lists
in that order (ORDER BY position, title).
- Each picker row has a goto-arrow that closes the popup,
switches to the card's bucket if needed, and opens the card.
- Relay DELETE /api/shopping removes deactivated lists. Client
proxy forwards DELETE. Deactivating a list in the picker
immediately removes it from the relay.
- Phone UI renders multiple lists as stacked boxes with headers.
Section headings (# h1–h6) get 14px top padding for breathing
room. CSS selectors fixed to match div-based headings inside
the new .shopping-list-box containers.
- Help text updated for multi-list wording.
- pullOne preserves the synced position so a pull doesn't cause
a spurious re-push of the same order.
Test fixes:
- Line-numbers gutter height tolerance bumped 50%→60% — OS text
shaping can push the divergence past 50%.
- Find/replace flaky: replaced sleep(200) with waitFor on the
confirm dialog so slow renders don't miss the OK click.
- Shopping picker test updated: checkboxes instead of radios.
Bumps to 1.9.25.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pick one #einkauf-tagged card as "active"; its body is pushed to the
relay on every sync.tick and rendered on a phone-friendly session-
auth'd page at /shopping. Phone shows only the open "- [ ]" items as
tappable rows — tapping flips the line to "- [x]" on the relay in
place. The next client tick pulls those toggles back and PATCHes the
local card, so hyprcrd stays the source of truth for item text.
- Relay: shopping_state table (one row per tenant), POST/GET
/api/shopping for content push/pull, POST /api/shopping/check
for phone toggles, GET /shopping for the phone UI.
- Client: hamburger menu in the header with an "Einkaufsliste"
entry that opens a picker listing all #einkauf cards with a
radio for the active one. Timestamp cursors in localStorage
keep push/pull idempotent.
- Phone can only toggle, not edit text — edit stays in hyprcrd,
which makes the merge trivial (last-writer wins by timestamp).
Multi-card support is there (any card with #einkauf is a candidate)
but only one is active for the phone at a time, per the spec.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds named group threads (chat_rooms + chat_room_members), mirroring
the collab-buckets membership pattern. Group messages share the
chat_messages table via a new nullable room_uid column; recipient_id
is left 0 for group rows and fan-out happens via the member-join at
GET time instead of duplicating per recipient.
Chat retention flips from "drop everything after 7 days" to
persistent-by-default: a new `persistent` flag on chat_messages marks
long-term rows (default 1 — all group messages plus non-disposable
1:1). The 7-day cleanup only targets persistent=0 rows so the sender
can still poll final receipt state before they're pruned.
New endpoints:
POST/GET/PATCH/DELETE /api/chat/rooms[/{uid}] — room CRUD
GET /api/chat/backfill — scrollback / cold-start history fetch
POST /api/chat — accepts recipient OR room_uid + disposable flag
GET /api/chat — returns 1:1 to caller AND rooms caller is in
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- replace chat-bot sync notifications with a dedicated relay_sync_events
queue: POST/GET /api/sync-events, drained by /api/relay/poll and
surfaced as stacking toasts via caldav.js. Old /api/chat/bot removed,
bot=1 rows purged on bootstrap, orange bot rendering dropped from
relay UI. Chat thread stays clean.
- collab pull: fetch attachments BEFORE applying the body so the moment
state.cards reflects the new reference the file is on disk. Fixes the
"preview shows broken/missing image, editor shows it" race where the
browser cached the 404 between attachment landing and user re-render.
- detail stale banner now compares title/body/color/tags in addition to
updated_at — robust against second-granularity timestamp collisions
and any other case where updated_at doesn't tick but content differs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Collab buckets sync attachments alongside cards (images/PDFs/audio).
New /api/collab-attachments endpoint on relay, encrypted at rest,
bucket-scoped for members.
- CalDAV multi-day events via **Bis:** body field. Calendar renders
event on every day in range with day counter (1/6, 2/6, ...).
- Calendar preserves month across close/reopen (only jumps to today
on first open).
- Calendar title: centered nav group with [MM-DD] Mon Y [KW-KW] —
dimmed brackets, fixed width so nothing wiggles.
- Editor: scratchpad uses custom confirm, autobackup-draft dialog
replaced with custom styled dialog.
- Scanlines theme feature removed (performance issue).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Read receipts on chat messages — single check for delivered (recipient
polled), double check for seen (recipient opened the thread). Pending
state shows a clock until the relay acks the post.
Relay (relay/):
* bootstrap.php: ALTER TABLE chat_messages ADD COLUMN seen + a
(sender_id, id) index for the sender-side state poll. Migration is
idempotent via PRAGMA table_info.
* routes/chat.php: two new routes
- GET /api/chat/sent?since=N — returns the receipt state (id,
recipient_id, fetched, seen, created_at) for messages the caller
sent. No body — sender already has the plaintext locally.
- POST /api/chat/seen { peer } — marks every message FROM peer TO
caller as seen=1. Idempotent. Frontend calls this when opening
a thread; sender's next /sent poll picks the new state up.
Plugin backend (public/plugins/chat/backend.php):
* schema migration: chat_messages gets `delivered` and `seen` columns
(idempotent), only meaningful for direction='out'.
* /api/chat/send: stores the relay's autoincrement id as `relay_id`
on the local outgoing row so the receipt poll can match against it.
* /api/chat/poll: after the normal incoming pull, also calls
GET /api/chat/sent?since=last_sent_relay_id and updates local
outgoing rows with delivered/seen via UPDATE WHERE relay_id. The
cursor (last_sent_relay_id state value) only advances past rows
whose state is final (seen=1); rows still in flight stay re-pollable.
Response now carries `receipts: <count>` so the frontend can refresh
the open thread when state changes without new incoming.
* /api/chat/read: after the local is_read=1 update, fires a POST to
the relay's /api/chat/seen so the sender gets the double-check.
Silent on failure — the user already saw the messages locally.
* /api/chat/messages: SELECT now includes delivered, seen.
Plugin frontend (public/plugins/chat/chat.js):
* renderThread() appends a receipt icon to outgoing bubbles:
- seen=1 → ph-checks (accent, double check)
- delivered=1, seen=0 → ph-check (muted, single check)
- both 0 → ph-clock (dim, pending)
Incoming messages get nothing.
* pollOnce() refreshes the open thread when receipts > 0 even with
no new incoming, so the icons update in real time while the user
is looking at the thread.
CSS (public/plugins/chat/chat.css):
* .chat-bubble-time becomes flex with gap so timestamp + receipt
icon sit on one line.
* .chat-receipt.pending / .delivered / .seen color tiers.
board: persist active bucket across reloads
* state.js: state.activeBucket initializes from
localStorage.getItem('hyprcrd-active-bucket') || 'all'. New
setActiveBucket(value) helper writes through to localStorage; new
ensureActiveBucketValid() helper falls back to 'all' if the
persisted id no longer exists in state.buckets.
* buckets.js: loadBuckets() calls ensureActiveBucketValid() right
after the API fetch and before renderBar(). All four production
assignments (delete-keep, delete-all, popover entry click, regular
tab click) replaced with setActiveBucket() so the persisted value
stays in sync. Tests still mutate state.activeBucket directly so
they don't touch localStorage.
chore: bump version to 0.9.6
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous lock implementation was advisory only — the relay's lock
table PK was (bucket_uid, card_uid, holder_id), so multiple users could
"hold" the same card simultaneously and the plugin only used the locks
to draw a small badge. Concurrent writers raced through LWW, and an
unsaved editor on User A could be silently overwritten when User B
saved (the symptom: cards appearing to "disappear" from User A's view
once their version was clobbered by an LWW pull).
Now an exclusive, server-enforced lock:
* relay schema: collab_card_locks PK is (bucket_uid, card_uid). One
lock per card. Migration drops the old 3-column-PK table on the
next bootstrap (locks are 60s ephemeral, no data to preserve).
* /api/collab-locks POST: returns 409 + holder info when another
tenant already holds it. Same holder refreshes the TTL.
* /api/collab-cards POST/DELETE: refuse with 423 + locked_by when a
foreign tenant holds the lock. The plugin's local edit is preserved
— the push just stays queued and retries on the next poll until
the lock expires or is released. Tombstones are gated the same way
so a card can't be deleted under the holder's editor.
* Plugin push phase: tracks blocked count + holder names, surfaces
them in the poll response so the frontend can toast the user.
Frontend lock-on-first-keystroke flow:
* The lock is no longer acquired on editor-open — opening for read
doesn't lock anything. On the FIRST input event in #inp-title or
#editor-body the plugin tries to acquire. On success, heartbeat
every 30 s; on 409, immediately enter editor takeover.
* Takeover mode: title + body inputs disabled, all three save
buttons get a capture-phase click blocker (stopImmediatePropagation
to defeat the editor's own listener), a window-level capture-phase
keydown blocker swallows Ctrl/Cmd+S (app.js binds save directly to
the shortcut, bypassing the buttons), and a banner inside the
modal explains the situation. The user can still copy their
unsaved text out before closing.
* If a foreign lock arrives via the regular poll while the editor is
already open on that card, the same takeover engages mid-session.
* Opening the editor on a card whose lock is *already* known to be
foreign starts in takeover mode without arming the input listeners
at all.
* Self-detection: poll response now carries `self` (the relay
username), cached as window._collabSelf, so we don't take ourselves
over and the board decoration ignores our own lock.
Visual:
* Board cards held by a foreign lock now get a frosted overlay +
centered lock icon + holder name (replaces the small corner badge).
* Editor takeover banner uses the danger accent. Save buttons get a
.collab-save-blocked dim style.
* New `.collab-lock-toast` variant of the existing conflict toast,
used for both "your push is queued because X is editing" and
"this card just got locked by X" notifications.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
End-to-end shared buckets between relay tenants. Bucket metadata + members
sync through new relay routes (collab-buckets, collab-cards, collab-locks);
card content rides the existing inter-tenant encryption hop. Edit locks,
ownership transfer, LWW conflict stashing, undo for local-bucket destructive
actions, "Make collaborative…" upgrade path, and a resilient outbox for
relay-down create attempts. See plugins/collab-buckets/README.md and
TODO.md for the full picture.
Core hooks added so the plugin can integrate without monkey-patching:
- buckets.js context menu: collab-aware rename/color/delete + Members &
owner / Make collaborative entries
- editor.js dispatches hyprcrd:editor-open / hyprcrd:editor-close for the
per-card edit-lock heartbeat
- app.js exposes window.state for the plugin's MutationObserver
- index.php POST/PATCH/DELETE on /api/buckets refuse to mutate collab
buckets directly (must go through the relay)
- Database.php adds buckets.collab_uid column + unique partial index
- import handler now restores color and collab_uid (was silently dropping
color, which caused the test runner's auto-restore to wipe custom
bucket colors)
- relay bootstrap creates collab_buckets, collab_bucket_members,
collab_cards, collab_card_locks tables and prunes tombstones / expired
locks on every request
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>