Skip to content

Promote preview to main (2026-09-28) - #243

Merged
EddyOne81 merged 42 commits into
mainfrom
preview
Sep 28, 2026
Merged

EddyOne81 merged 42 commits into
mainfrom
preview

Conversation

@EddyOne81

Copy link
Copy Markdown
Collaborator

Promotion preview → main (prod) — requested by Natrix

Ships everything on preview (UAT, already running on the prod DB) to main for the manual PROD dispatch.

  • 34 non-merge commits, 40 files changed, 4762 insertions(+), 316 deletions(-)
  • git merge-tree main/preview: clean. Every main-only hotfix commit verified present in the merged tree by content.
  • Reviewed git diff main...preview: no unexpected deletions of live code, no secrets.

🤖 Generated with Claude Code

luongtrieuvy202 and others added 30 commits September 22, 2026 00:18
…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
…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).
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>
phamtobao@gmail.com and others added 12 commits September 26, 2026 02:24
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)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread service/private/hub.js
Comment on lines +1582 to +1584
await this.yp.await_proc(
"yp_add_pending_invitation", hubId, 0, privilege, email
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread service/media.js
Comment on lines +795 to +798
const filesize = Number.parseInt(this.input.need(Attr.filesize), 10);
if (!Number.isInteger(filesize) || filesize <= 0) {
return this.exception.user("INVALID_FILESIZE");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@EddyOne81
EddyOne81 merged commit 7fb16c4 into main Sep 28, 2026
6 of 7 checks passed
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.

5 participants