Conversation
…e channel contact.invite_accept greets a brand-new contact from both sides (_contact_invite_chat_msg / _contact_accept_chat_msg). handshake() posted both through channel_post_message into the two drumates' legacy `channel` tables, which breaks the invariant patches/changelog.txt states: `channel` is one table per WORKSPACE, `p2p_channel` one per USER. Two consequences. The greeting never reached the conversation it was for — the contact chat reads p2p_channel (chat.messages -> p2p_list_messages) and was never looking at `channel`. And it leaked into workspace chat: the drumate branch of channel_post_message does not persist entity_id at all (the peer survives only in time_channel), so channel_list_messages cannot filter by peer, and since the personal hub's id IS the viewer's uid, its window's "Team Chat" runs channel.messages straight against the drumate DB. Every contact's handshake, from every contact, rendered as one conversation. Post it the way chat.post does instead (chat.js _distributeMessage): a single p2p_post_message write in the author's own DB with peer_id set, the SP updating the recipient's p2p_time cross-DB. Both SPs, and count_yet_read_next, are already deployed to every drumate DB. The chat.post WS push is oriented per recipient — peer_id is the sender from the recipient's side, which is what their widget matches its peerId against — and carries the author resolved through shareroom_contact_get, so a bubble landing on an already-open conversation renders with a name. No echoId: a server-written greeting echoes no pending bubble. The legacy acknowledge_message call is gone; p2p_post_message marks the author's own copy seen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task attachments now upload into the hub's hidden task folder (/__chat__/__task__) instead of the workspace body, so that they stop appearing in the folder's Files tab. That closes one hole and opens another: the file used to stay visible in the folder after an unlink, where the user could still delete it, and would now sit in a folder no listing shows with no way for anyone to reclaim the space. _purgeUnlinkedFiles drops the media node once neither task_file nor task_comment_file names it any more, from all four paths that can remove the last link: unlink_file, comment_unlink_file, delete and comment_delete. The last two read the nids BEFORE the SP removes the rows that name them. Only files UNDER /__chat__/__task__ are touched, so an attachment linked from the workspace body — a document in its own right — is left exactly where it is. mfs_attachment_remove re-checks '^/__chat__' itself, so a wrong nid reaching here still cannot delete someone's file, and comment_delete purges only when affected says something was actually removed, or a non-author could take another's attachment with it. It never throws: failing to reclaim a file must not fail the unlink or the delete the user actually asked for. Attachments already sitting in folder bodies match none of this and are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inviting somebody who already had a Drumee account added them to the workspace on the spot. They were never asked: the first they knew of it was a workspace appearing in their sidebar, and the email that followed announced something already done. There was no accept and no decline because there was nothing left to answer. Both branches of hub.invite now mint an invitation and stop. Membership is written by accept_invite, when the person says yes -- from the email's Accept link or from the notification row. WHAT IS LEFT OF THE ACCOUNT-STATUS BRANCH is one thing: whether there is a Drumee user to notify. The token, the pending row, the audit line, the tracking and the email are now identical for both, which is the point -- two half-shaped invitations that had to be told apart everywhere downstream are one. hub.decline_invite is new and takes NO SESSION. Holding the secret is the authorisation, exactly as it already was for redemption, and this is the strictly less dangerous of the two: accepting with a secret grants access to a workspace, declining only destroys an invitation addressed to one address. Requiring a sign-in would mean the one answer that needs no account could only be given by creating one. It never removes membership -- that is desk.leave_hub -- and the proc refuses anything that is not an ACTIVE hub_invite token, so a stale link pressed after joining cannot rewrite how somebody got in. hub.invitations backs the Access panel's Pending Invitations section. Current members are filtered out HERE rather than in the proc, because membership lives in the workspace database while hub_invitations reads yellow_page -- the case it catches is an address invited while an older invitation was still open, then added directly by add_contributors, which asks nobody. 🚨 THE CHAT STAGING GRANT, which accept_invite has never written. A member whose role is Chat has no write bit for the workspace and is meant to get write on the hidden '/__chat__/__upload__' folder alone. _grantMembership writes that grant; redeeming a token did not, so anybody who joined by link could not attach a file to a chat -- the same 403 that was chased and fixed on the other path (schemas 2026-09-17, chat_upload_grant_repair). Survivable while redemption was the rare branch; it is now how every member joins, so leaving it out would reintroduce that bug as the normal case on the one path nobody had looked at. _trackInviteSent now picks its procedure and the CALLER must say which. invite() passes answerable:true for invite_track_mark_v2, which leaves accept_time NULL. The default stays the old procedure because invite_with_roles still grants immediately -- a default that changed under it would record every one of its grants as an invitation nobody answered, with the same arity, no error and a wrong number. _closeInvitation is the cleanup both answers share. The one that matters is pending_invitation: that table is the QUEUE signup grants membership from, not a record, so a refusal that only marked the token would be undone by the invitee signing up afterwards -- declining a workspace and then finding themselves in it. The notification row carries the token out as `invite_token` on all THREE surfaces the same row reaches: hub.invite_received_get, the Unread-ON rollup (mapHubInviteRow) and the default feed's raw row (_stampHubInvites). Missing one is how file notifications got a dead click in September. Rows written by _grantMembership carry no token and correctly render without buttons -- they are a receipt, not an invitation. The email offers both answers and stops claiming the deed is done: subject, title, headline, body and footer all said "added you to" or "now have access", which were true only while this mail followed a membership that had already been written. Verified by rendering the template both ways -- internal and external -- with no stale phrase left outside an HTML comment, one accept link, one decline link, and the & correctly escaped. 🚨 lodash templates have no comment delimiter. An EJS-style comment tag compiles as evaluate and throws SyntaxError, taking every invitation email with it -- hit while writing the comment that now warns about it, and the warning could not spell the tag out either, because lodash scans the whole file including HTML comments. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(hub): send invite emails in batches of five instead of one awaited send each
fix(hub): send invite emails 25 at a time, the SMTP session cost dominates
…d send each (cherry picked from commit 52e7f8e)
…nates (cherry picked from commit c4055b6)
…alling socket (#229) Co-authored-by: Drumee Dev <drumee@debian.local.drumee> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Access panel's Pending Invitations section came back empty for an org over its seat limit — 401 OVER_LIMIT_READ_ONLY:hub.invitations, measured on the dev endpoint. The clamp classifies a service as mutating by `permission.src > READ_LEVEL`, and hub.invitations is src:'admin' because only an admin may see other people's addresses. It writes nothing. That is precisely the trap the admin./adminpanel. family exemption a few lines below is documented against: src:'admin' is a PRIVILEGE requirement, not a mutation marker. Left as it was, the section would be blank for exactly the org that most needs to read it — one that is over its seat limit and trying to work out who it has outstanding invitations to. hub.invite itself stays clamped, so nothing added here can grow the overage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_addInviteToken now calls token_hub_invite_supersede after minting, so a person has exactly one live invitation per workspace. The REPLACE inside token_hub_invite_add only covers a re-send by the SAME admin — its unique key carries inviter_id — so a second admin inviting the same address left the first row beside the new one, refusals included. A declined row is parked with expiry 0 so it never ages out, and hub_invitations reports the newest row that still qualifies: once the NEW invitation lapses, the old refusal is the only one left and the panel says "Declined" for an invitation the person never answered. Best-effort: the invitation is already minted and must not fail because a tidy-up did. The worst case is the stale row this removes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Answering an invitation only dismisses its notification (the row stays in the history on purpose) and the row keeps its token, so the client kept drawing Accept/Decline after a Decline: pressing Decline again did nothing and Accept reported an invalid link. get_feed now resolves each invitation row's token through token_get_next (the read accept_invite uses) into pending | accepted | declined | expired | invalid. One lookup per distinct token, capped at 20 per page, add-only and best-effort: a failed lookup leaves the field absent and the client falls back to the old token-only behaviour. Pages without invitations make no extra query. Covered by offline/test/hub-invite-status.test.js (32 cases incl. negative controls). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-accept-flow Conflict in hub.invite: test now sends the invite emails in batches of 25 as ONE message per batch, while this branch puts each invitee's own token in the Accept/Decline links. Kept test's batching (mailQueue, positional results, INVITE_MAIL_BATCH, _sendInviteEmails) but each batch is sent as concurrent single-recipient sends with per-invitee template data, so nobody receives another person's invitation. Same SMTP profile: Messenger shares one module-level transport and posts one sendMail per recipient either way. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ered A decline changes no membership, so no push reached the workspace and an admin's open Access panel kept showing the invitation as Pending until it was reopened. _closeInvitation (shared by accept_invite and decline_invite) now ends by pushing hub.invitations_changed to the hub's online members. The payload is the hub id alone: the answer is read back through the admin-gated hub.invitations, so no email rides on a push every member's socket receives. Never throws, like the other member pushes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
feat(invite): invitations are answered with Accept/Decline instead of auto-joining
channel.post copied a staged workspace attachment into the sbox with the detached mfs-copy-node.sh script and, in the same request, purged the staging node with rm -rf. The purge won the race almost every time, so cp found nothing and the committed message pointed at an empty sbox folder: no vignette on the card, and Open hit a 404 on file/orig. Copy the node storage with an awaited fs.cp (following the staging symlink the desk copy leaves while it is still running) so the purge only runs once the bytes are in place.
Debug report covering the From-device attachment leaking into the workspace Files list, the From-workspace picker rework, and the channel.post copy-then-purge race that left sbox attachment folders empty (fixed in c484f8d).
…/status/complete/abort)
feat(upload): chunked, parallel, resumable uploads
A post used to copy every verified staging node into the sbox with a detached script, then delete the staging node in the same request, and chat.attachment handed the bubble a fixed page of five that nothing ever paged past. A 36-file message took 5.85 s server-side and showed 5 cards. Verified staging nodes are now moved (mfs_move_all + rename) so the cost no longer depends on file size and no purge step remains; nodes the classifier did not vouch for keep the copy path. The classifier looks up all nids in one query, and chat.attachment returns the whole list. Stage replay: 36 files post in 1.2 s, 36 cards, no empty sbox folders, no staging leftovers.
mfs_move_all reports a move inside one hub as 'show'/'same' with no 'move' row, so a staged node moved into a team or share hub's own sbox produced no attachment entry. The node keeps its id there, so the sources themselves are the entries.
The invitation popup gets a "View Calendar" button that opens the meeting in its workspace calendar. The push only carried the meeting's nid, which is a node id inside ONE hub's database and cannot be opened without the hub. Add hub_id and the parent folder — the same hub_id/pid the durable meeting_notice row already carries. Additive only; no schema change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Expose myDrumee.tours_new_user_since (unix seconds) as a platform flag. The client compares it with the account's entity.ctime so contextual tutorial tours are offered to new accounts only. 0/absent keeps today's behaviour (every account eligible), so deploy order is free. Co-authored-by: Drumee Dev <drumee@debian.local.drumee> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(env): tours_new_user_since cutoff for contextual tours Expose myDrumee.tours_new_user_since (unix seconds) as a platform flag. The client compares it with the account's entity.ctime so contextual tutorial tours are offered to new accounts only. 0/absent keeps today's behaviour (every account eligible), so deploy order is free. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: guide to enable the tours_new_user_since cutoff Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Drumee Dev <drumee@debian.local.drumee> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
metadata._seen_ never forgets a uid, so a member who left, was removed, lost an expired grant or deleted the account kept appearing as a seen avatar in the workspace chat. channel.messages and channel.file_thread_messages now prune _seen_ to the hub's current readers (channel_reader_ids) before responding and broadcasting. The lookup fails open, so a hub without the procedure still lists its chat.
Both workers built parent_path/file_path with path.join. At a hub root
join("", "") is ".", so an unzipped folder was stored as "." / "cye" and
its children as "cye" / "cye/x.pdf". Exact file_path lookups and
node_id_from_path then missed every node inside it: uploads into the
folder landed at the workspace root, mkdir -p failed, and URLs came out
as "/Hubcye/x.pdf". Into a subfolder, parent_path lost its trailing
slash.
Paths now come from service/lib/mfs-path.js in the shape parent_path()
and filepath() return ("/cye", "/cye/", "/cye/x.pdf"). A destination
that still holds a legacy relative path is normalised against the hub
root. Folders are booked at 0 bytes like media.make_dir, not 1024.
serverimport also stops writing "name.undefined" for top-level files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(media): trash every node a batch request names
Users kept getting "X started a meeting" for meetings that were long under
way, already over, or in workspaces they are not members of.
- Announce conference.start to the workspace only when the joiner is the
room's first, and once per meeting (Redis SET NX marker). A host rejoin,
a media Retry, a member promoted to host in a hostless room, and the first
join after a server restart wiped yp.conference no longer re-announce.
- The marker is a heartbeat: 5 min TTL re-armed by every participant's
30 s diagnostics ping, so a dead room frees its workspace within minutes
and never swallows the next genuine meeting's announcement.
- Send it to workspace members only ('*' grant, live, not expired).
entity_sockets matched any permission row, e.g. a single shared file.
entity_sockets itself is unchanged (every hub broadcast uses it).
- When a meeting room empties (conference.leave or a dropped socket), flip
its still-live "started a meeting" chat cards to ended and clear the
marker. The card used to be flipped only by a clean client teardown, so a
closed/crashed tab left it offering "Join meeting" forever, and joining
from it started a new meeting in the clicker's name. The dropped-socket
path now also clears the duration-cap start, like the clean leave does.
Everything fails open: no Redis → first-joiner rule; unknown membership →
previous recipient list; card cleanup errors never fail a leave.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
channel.messages stamps the viewer into _seen_ on every message up to the newest each time it loads. The UI mounts the workspace team chat as a side column on the Files tab, so merely opening a workspace told the team the viewer had read the whole conversation. Optional `mark_read: 0` skips channel_read_messages; the client then marks read through channel.acknowledge when the chat is actually read. Absent = unchanged, so every other caller keeps the old behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
activity.dismiss_rollup matched the live rollup on its display key_id, but the client sends the key the dismiss acts on: the folder nid for media, the peer's drumate id for chat. Those differ (key_id is the uploader / the contact), so every such read answered INVALID_DATA and the notification came back unread on reload; even a match would have called notification_dismiss with the wrong key. rollupDismissKey() is now the one place that picks that key, shared by mark_all_read (unchanged behaviour) and the per-row read. The live-row check (category, hub, last_id) is kept; forged and stale keys are still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unread OFF marked every undismissed contact_activity row unread, but the tab badges and Unread ON only count events that have an unread source. invite_sent (a second row for the same invitation), invite_received once no longer pending and superseded workspace invites looked unread with no badge counting them (All 14 over 19 unread-looking rows, Other 0 over 5). - CONTACT_UNREAD_PROCS: the one list unread_counts, Unread ON and the new Unread OFF read state are built from; adds contact_invite_accepted_unread so "accepted your invitation" counts. - _alignContactReadState (Unread OFF): a contact row stays unread only if it is counted: an *_unread row, a live workspace-invite or refused rollup, or the newest invite_received of a pending invitation. Only unread -> read, fails open, no cost without unread contact rows; page 1 reuses the rollups already fetched. - Mark as all read on Other now clears everything Other counts: storage alerts, reward expiry, accepted invitations, workspace invites (per hub) and refused invitations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
activity.read_contact_event marks one contact_activity row read via contact_activity_mark_read (dismissed_at only). dismiss_contact_event keeps its removal meaning for mobile. Tab-scoped Mark as all read (Task / Meeting / Other) now marks those rows read too instead of hiding them, as the unscoped one always did. Until the new proc is applied, _markContactRead falls back to contact_activity_dismiss so a read is never lost during a rollout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- bookmark_add accepts an optional `row`; it is kept as a snapshot only when bookmarkKey(row) reproduces the key and it is under 16 KB. Without it (mobile) nothing changes. - bookmark_remove also removes the snapshot. - New activity.bookmark_rows: the saved rows, newest first, optional tab bucket, deleted rows dropped, served is_read=1 (the client prefers the live feed row, which carries the true state). get_feed is untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(media): optional sort on show_bin (latest/earliest/expiring) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(trash): name the sorted bin proc mfs_show_bin_sorted mfs_show_bin_next already exists as a one-arg proc (factory templates, 1668 stage instances). Reusing the name with two args would change its arity on a shared DB, and new instances built from the templates would answer an argument-count error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Drumee Dev <drumee@debian.local.drumee> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
release: promote test to preview (UAT, 2026-09-27)
…r workspace
For the desk rail's Files pill (Duy 2026-09-27): uploads, new files AND new
folders of a workspace, counted by the new drumate routine mfs_new_by_hub
(schemas 6610321). Optional `files_since` = { "<hub_id>": ts } — the web
client's "Files tab last opened over" marks — so only what came after counts.
Additive and fail-safe:
- a new field on unread_counts, not added to `files` or `all` (those already
count every changelog event), so every current number is unchanged and
clients that ignore it are unaffected;
- files_since is sanitised (16-hex hub ids, positive ints, <= 500 keys) and
passed as a bound JSON parameter;
- [] while the routine is absent, with the MISSING_PROCS cooldown keyed per
user DB so one stale DB never silences another; never throws.
No new request: the panel already calls unread_counts on every refresh.
Checked with stubs (sanitiser, mapping, cooldown per DB, error path);
server suite: the only failures are the pre-existing invite-mail ones
(present on clean origin/test too).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94f736e)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a05c2631e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| await this.yp.await_proc( | ||
| "yp_add_pending_invitation", hubId, 0, privilege, email | ||
| ); |
There was a problem hiding this comment.
Require acceptance before creating membership
These new pending rows are consumed automatically by signup._resolve_pending_invitation, which calls add_member and permission_grant for every pending row matching a newly registered email. Thus an invitee without an account who simply signs up with the invited address—without using the accept link or calling accept_invite—is added to the workspace while the token remains active. This bypasses the new accept/decline flow and can enroll recipients who never accepted; defer this row until acceptance or make the signup resolver require an accepted token.
Useful? React with 👍 / 👎.
| const filesize = Number.parseInt(this.input.need(Attr.filesize), 10); | ||
| if (!Number.isInteger(filesize) || filesize <= 0) { | ||
| return this.exception.user("INVALID_FILESIZE"); | ||
| } |
There was a problem hiding this comment.
Cap chunk-session chunk counts
A writer can declare an arbitrarily large integer here (the ACL has no maximum), and createSession accepts it as a sparse file. Calling upload_complete immediately then makes ChunkedUpload.missing() iterate over and serialize every declared chunk index, so a huge sparse session can consume the single Node process's CPU and memory without uploading any bytes. Enforce a server-side maximum filesize or total chunk count before creating the session.
Useful? React with 👍 / 👎.
Promotion
preview→main(prod) — requested by NatrixShips everything on
preview(UAT, already running on the prod DB) tomainfor the manual PROD dispatch.git merge-treemain/preview: clean. Every main-only hotfix commit verified present in the merged tree by content.git diff main...preview: no unexpected deletions of live code, no secrets.🤖 Generated with Claude Code