From eb35ef92a0d171f125e7f9c452e26815d42938cd Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:55:13 +0000 Subject: [PATCH 01/36] feat(core): add ClassCollaborators.mentionsGrantAccess opt-in flag Adds a model-level boolean that classes can set alongside provideSecurity to indicate that @-mentions in chat/activity messages on docs of this class should auto-create Collaborator records, granting the mentioned user explicit, disclosed access. The flag has no effect unless provideSecurity is also true. provideSecurity itself is unchanged (read-visibility OR-branch through the SpaceSecurity middleware). Without mentionsGrantAccess, todays "mention is a no-op silently" behavior is preserved for QMS and Love classes. Step A1 of the plan in /opt/infrastructure/docs/superpowers/plans/ 2026-05-25-huly-mention-grants-collaborator-access.md. The accompanying model wiring (A2), Tracker opt-in (A3), middleware veto (A4), shared helper (B-1), chunter trigger update (B-2), and client surfaces (B.1, B.2, C) follow in subsequent commits. Refs hcengineering/platform#10783, #9741. Signed-off-by: Michael Uray (cherry picked from commit fcc426fa2214079e8fbff7f2074b7778b733bc7b) Signed-off-by: Michael Uray --- foundations/core/packages/core/src/classes.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index b9c62466d90..07a9baa37d0 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -1010,8 +1010,15 @@ export interface ClassCollaborators extends Doc { attachedTo: Ref> allFields?: boolean // for all (PersonId | Ref | PersonId[] | Ref[]) attributes fields: (keyof T)[] // PersonId | Ref | PersonId[] | Ref[] - provideSecurity?: boolean // If true, will provide security for collaborators + // If true, Collaborator status grants read visibility on the doc, + // bypassing space-membership. Writes are governed by the class's + // TxAccessLevel and any pre-commit middleware (see GuestPermissions). + provideSecurity?: boolean provideAttachedSecurity?: boolean // If true, will provide security for collaborators of attached doc + // If true, @-mentions in chat/activity messages on this doc auto-create + // Collaborator records (mention = explicit, disclosed grant). Has no + // effect unless provideSecurity is also true. + mentionsGrantAccess?: boolean } export interface Collaborator extends AttachedDoc { From 1d4017e5adb29308e2df3bd17a8b1a65f253f629 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:55:43 +0000 Subject: [PATCH 02/36] feat(model/core): wire mentionsGrantAccess into TClassCollaborators model class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the new field in the runtime model schema. Follows the existing bare-annotation pattern used for the other ClassCollaborators fields (no @Prop decorators on this model class today). Field is model-internal — never appears in user-facing labels, so no IntlString or locale entries needed. Step A2. Signed-off-by: Michael Uray (cherry picked from commit 91359230610a9d9ce3d2da333e476753e3102fc2) Signed-off-by: Michael Uray --- models/core/src/core.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/models/core/src/core.ts b/models/core/src/core.ts index d6f4588a905..310a17f3dce 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -438,6 +438,7 @@ export class TClassCollaborators extends TDoc implements ClassCollaborators fields!: (keyof Doc)[] provideSecurity?: boolean provideAttachedSecurity?: boolean + mentionsGrantAccess?: boolean } @Model(core.class.Collaborator, core.class.Doc, DOMAIN_COLLABORATOR) From 0b9fa6a9fae78290c44e4ab4e4db3cd0786aace9 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:56:26 +0000 Subject: [PATCH 03/36] feat(tracker): opt tracker.class.Issue into collaborator-grants-read + mentions-grant-access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets provideSecurity:true and mentionsGrantAccess:true on the Issue ClassCollaborators declaration. This is the single class opting into the new behavior in this PR — QMS, Love, Cards and other classes are unaffected. Effect together with subsequent commits: - A user @-mentioned on an Issue they are not a project-member of is auto-added as Collaborator on the Issue (B-2 chunter trigger). - The Collaborator record grants them read visibility (provideSecurity in SpaceSecurity middleware). - They can post comments (chunter.class.ChatMessage createAccessLevel is already Guest). - They cannot edit Issue fields (A4 GuestPermissions middleware veto). Step A3. Signed-off-by: Michael Uray (cherry picked from commit bbf25b667461793c2964181983d10bdd2a29fb2e) Signed-off-by: Michael Uray --- models/tracker/src/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index baabea1381a..1296c8c1f17 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -523,7 +523,17 @@ export function createModel (builder: Builder): void { builder.createDoc>(core.class.ClassCollaborators, core.space.Model, { attachedTo: tracker.class.Issue, - fields: ['createdBy', 'assignee'] + fields: ['createdBy', 'assignee'], + // Collaborator status grants read visibility on the issue, bypassing + // project-space membership. Used so a user @-mentioned on an issue + // they are not a project member of can actually see and comment on it. + provideSecurity: true, + // @-mentions in chat/activity messages on an issue auto-create a + // Collaborator record (server-plugins/chunter-resources). Combined + // with provideSecurity above, the mentioned user gets explicit, + // disclosed read+comment access. Field writes remain blocked for + // collab-only guests by the GuestPermissions middleware veto. + mentionsGrantAccess: true }) builder.mixin(tracker.class.Issue, core.class.Class, setting.mixin.Editable, { From 2d28f4bdd6f0e0f7cdd203320d67026715b5cec6 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:58:41 +0000 Subject: [PATCH 04/36] feat(core/collaborators): add isomorphic resolveMentionGrantTarget helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks a Docs attachedTo chain (depth-capped at 8) to find the nearest ancestor whose ClassCollaborators has both provideSecurity:true AND mentionsGrantAccess:true. Returns that ancestor as the grant target, or null if none. Isomorphic via the findAll dependency injection — same code used by both the server-side chunter mention-trigger (B-2) and the client-side mention warning popup (C), so the disclosure UX matches the actual server-side grant. For ThreadMessage mentions: walks from ThreadMessage -> parent ChatMessage -> parent Issue, and writes Collaborator on the Issue, not on the thread or chat message. Step B-1. Signed-off-by: Michael Uray (cherry picked from commit f7b9a9909b36ab21e3fc92994d02576158e66779) Signed-off-by: Michael Uray --- .../core/packages/core/src/collaborators.ts | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/foundations/core/packages/core/src/collaborators.ts b/foundations/core/packages/core/src/collaborators.ts index 1574a2c1401..775bf116792 100644 --- a/foundations/core/packages/core/src/collaborators.ts +++ b/foundations/core/packages/core/src/collaborators.ts @@ -13,7 +13,16 @@ // limitations under the License. // -import core, { Class, ClassCollaborators, Doc, Hierarchy, ModelDb, Ref } from '.' +import core, { + AttachedDoc, + Class, + ClassCollaborators, + Doc, + DocumentQuery, + Hierarchy, + ModelDb, + Ref +} from '.' export function getClassCollaborators ( model: ModelDb, @@ -35,3 +44,49 @@ export function getClassCollaborators ( } } } + +/** + * Walk a Doc's attachedTo chain to find the nearest ancestor (including the + * Doc itself) whose ClassCollaborators has BOTH provideSecurity===true AND + * mentionsGrantAccess===true. Returns that ancestor Doc as the grant target, + * or null if no such class is reached within the depth cap. + * + * Used by both the chunter mention-trigger (server) and the warning popup + * (client) so the disclosure UX matches the actual server-side grant. The + * helper is isomorphic via the findAll dependency injection — server passes + * `(cls, q) => control.findAll(control.ctx, cls, q)`, client passes + * `(cls, q) => getClient().findAll(cls, q)`. + * + * The ClassCollaborators lookup is exact-class (not inherited via ancestors) + * — adequate for tracker.class.Issue and avoids surprising matches on + * abstract base classes like AttachedDoc. If future opt-in classes need + * inherited semantics, switch to `getClassCollaborators(model, hierarchy, _class)` + * here (requires plumbing ModelDb + Hierarchy through the dependency + * injection — kept out for now to keep the helper isomorphic without + * the ModelDb tax on the client). + * + * Depth cap (8) defends against pathological attachedTo cycles. + */ +export async function resolveMentionGrantTarget ( + start: Doc, + findAll: (cls: Ref>, q: DocumentQuery) => Promise +): Promise { + let cur: Doc | undefined = start + for (let i = 0; i < 8 && cur != null; i++) { + const cc = (await findAll(core.class.ClassCollaborators, { + attachedTo: cur._class + } as DocumentQuery>))[0] + if (cc?.provideSecurity === true && cc.mentionsGrantAccess === true) { + return cur + } + const attached = cur as AttachedDoc + if (attached.attachedTo == null || attached.attachedToClass == null) { + return null + } + const parent = (await findAll(attached.attachedToClass, { + _id: attached.attachedTo + } as DocumentQuery))[0] + cur = parent + } + return null +} From b67689ca96cc8af72750adf7e97a3643748f7c6c Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 20:58:03 +0000 Subject: [PATCH 05/36] feat(middleware/guest-permissions): veto field updates from collab-only guests on opt-in classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds isForbiddenCollabOnlyGuestFieldUpdate to GuestPermissionsMiddleware as a class-agnostic, pre-commit veto. Triggered only when the targeted class has both provideSecurity:true AND mentionsGrantAccess:true on its ClassCollaborators model entry. Tracker Issue is the first opt-in (set in models/tracker/src/index.ts), but any future class with the same flags inherits identical semantics with zero additional code. Semantics: - User+ accounts pass through (unchanged). - Space-member guests pass through (their existing rules apply). - Guest-tier accounts that are NOT space-members but reach the doc via Collaborator status (provideSecurity OR-branch in SpaceSecurity middleware) get a Forbidden when they try to TxUpdateDoc the doc. Effect for the user-visible scenario in #10783: - Florian @-mentioned on GAME-4 in a project he is not a member of becomes Collaborator on GAME-4 (auto, via B-2 chunter trigger). - He gets read visibility (provideSecurity). - He can post comments (ChatMessage createAccessLevel: Guest). - He CANNOT modify GAME-4 (status, title, dates, ...) — this veto. - Florian editing his own OSKOS-12 in Ostrowo where he IS a member is unaffected: space.members.includes(florian) → veto returns false. Step A4. Refs #10783, #9741. Signed-off-by: Michael Uray (cherry picked from commit 8e825a54db51f6b5eaa392d8d961a37ad4da2f7b) Signed-off-by: Michael Uray --- .../middleware/src/guestPermissions.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/foundations/server/packages/middleware/src/guestPermissions.ts b/foundations/server/packages/middleware/src/guestPermissions.ts index 077d546a84d..31415c5b786 100644 --- a/foundations/server/packages/middleware/src/guestPermissions.ts +++ b/foundations/server/packages/middleware/src/guestPermissions.ts @@ -10,6 +10,7 @@ import core, { type Class, type Doc, type ClassPermission, + getClassCollaborators, type Permission, hasAccountRole, type MeasureContext, @@ -157,9 +158,56 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle } else if (cudTx.space !== core.space.DerivedTx && (await this.isForbiddenTx(ctx, cudTx, account))) { throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) } + if (await this.isForbiddenCollabOnlyGuestFieldUpdate(ctx, cudTx, account)) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } } } + /** + * Class-agnostic veto for field updates on docs whose class has opted into + * mention-grants-access. The opt-in is the pair (provideSecurity: true, + * mentionsGrantAccess: true) on the class's ClassCollaborators model entry. + * + * For such classes, a guest-tier account that obtained read visibility ONLY + * through Collaborator status (i.e. is NOT in the doc's space.members) must + * not be able to modify the doc's fields via TxUpdateDoc. Comments via + * chunter.class.ChatMessage createAccessLevel still pass through. + * + * Space-member guests retain their current behavior — they pass through + * this check untouched and their normal access rules continue to apply. + * User+ accounts always pass through. + * + * If the class has not opted in, this veto is a no-op (returns false). + */ + private async isForbiddenCollabOnlyGuestFieldUpdate ( + ctx: MeasureContext, + cudTx: TxCUD, + account: Account + ): Promise { + if (cudTx._class !== core.class.TxUpdateDoc) return false + + const isGuest = + account.role === AccountRole.Guest || + account.role === AccountRole.DocGuest || + account.role === AccountRole.ReadOnlyGuest + if (!isGuest) return false + + const classCollab = getClassCollaborators( + this.context.modelDb, + this.context.hierarchy, + cudTx.objectClass + ) + if (classCollab?.provideSecurity !== true) return false + if (classCollab.mentionsGrantAccess !== true) return false + + const space = (await this.findAll(ctx, core.class.Space, { _id: cudTx.objectSpace }))[0] + if (space === undefined) return false + if (space.members?.includes(account.uuid) === true) return false + + return true + } + /** * Returns the covered-class ancestor of the objectClass if one exists in the new permissions model, * or undefined if the class is not covered. From be38db3fd52d8c1b8bc753155069ea1359fb4b75 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Thu, 9 Jul 2026 07:07:59 +0000 Subject: [PATCH 06/36] feat(tracker/utils): split canEditIssue into canEditIssueFields + canCommentOnIssue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously canEditIssue conflated "may edit fields" with "may comment". A guest who was Collaborator on an issue ended up with all field editors enabled (too permissive) — or, when blocked, lost the comment composer too (too restrictive). The split: - canEditIssueFields: Issue field editors (title, status, dates, description, ...). Returns false for any guest-tier account. - canCommentOnIssue: Comment composer. Returns true for User+, false for ReadOnlyGuest, and for Guest/DocGuest true when the user is the Issues creator OR listed as Collaborator on the issue. Existing canEditIssue is kept as a backwards-compat alias for canEditIssueFields until callers are migrated. EditIssue.svelte already migrates in the next commit (B.2). Pairs with the server-side veto in A4 (GuestPermissions middleware) that rejects TxUpdateDoc for collab-only guests at the tx layer — so a guest cannot route around this UI gate via raw API. Step B.1. Signed-off-by: Michael Uray (cherry picked from commit b49c7c59e815433a0a38f7d25248d2bc078110a5) Signed-off-by: Michael Uray --- plugins/tracker-resources/src/utils.ts | 48 ++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/plugins/tracker-resources/src/utils.ts b/plugins/tracker-resources/src/utils.ts index c0afa1ed7bc..4946a1ac00b 100644 --- a/plugins/tracker-resources/src/utils.ts +++ b/plugins/tracker-resources/src/utils.ts @@ -277,8 +277,21 @@ export async function moveIssuesToAnotherMilestone ( } } -export async function canEditIssue (issue?: Issue | WithLookup): Promise { - const client = getClient() +/** + * True when the current user may edit Issue fields (title, description, + * status, dates, labels, assignee, dependencies, etc). + * + * Guest-tier accounts are blocked unconditionally — even when listed as + * Collaborator on the issue. Their Collaborator status grants read + + * comment via {@link canCommentOnIssue}, but not field-edit. This matches + * the server-side veto in the GuestPermissions middleware so the UI + * doesnt show editors that would fail on submit. + * + * Project-member guests (e.g. a guest who is a member of their own + * project) are also blocked here; if you need to allow them to + * edit issues they own, promote them to AccountRole.User+. + */ +export async function canEditIssueFields (issue?: Issue | WithLookup): Promise { if (issue === undefined) return false const account = getCurrentAccount() @@ -286,14 +299,35 @@ export async function canEditIssue (issue?: Issue | WithLookup): Promise< account.role === AccountRole.Guest || account.role === AccountRole.DocGuest || account.role === AccountRole.ReadOnlyGuest + return !isGuest +} + +/** + * True when the current user may post comments on the Issue. + * + * - User+ accounts always pass. + * - ReadOnlyGuest is always blocked. + * - Other guests pass if they are the Issues creator OR they are listed + * as Collaborator on the issue (auto-added via @-mention by the + * chunter trigger when mentionsGrantAccess is set on the class, or + * added manually via the Collaborators editor in the panel). + */ +export async function canCommentOnIssue (issue?: Issue | WithLookup): Promise { + if (issue === undefined) return false + const account = getCurrentAccount() + if (account.role === AccountRole.ReadOnlyGuest) return false + + const isGuest = + account.role === AccountRole.Guest || + account.role === AccountRole.DocGuest if (!isGuest) return true const isCreator = issue.createdBy !== undefined && Array.isArray(account.socialIds) && account.socialIds.includes(issue.createdBy) - if (isCreator) return true + const client = getClient() const collaborator = await client.findOne(core.class.Collaborator, { attachedTo: issue._id, collaborator: account.uuid @@ -301,6 +335,14 @@ export async function canEditIssue (issue?: Issue | WithLookup): Promise< return collaborator !== undefined } +/** + * Backwards-compat alias for callers that have not yet migrated to + * {@link canEditIssueFields}. New code should use the explicit split. + * Tag for removal in a follow-up sweep across tracker-resources. + */ +export const canEditIssue = canEditIssueFields + + export function getTimeReportDate (type: TimeReportDayType): number { const date = new Date(Date.now()) From f07d6e0c40e5f0b57be22d23dafc57f25a499e61 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 21:02:00 +0000 Subject: [PATCH 07/36] feat(tracker/EditIssue): show comment composer separately from field editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EditIssue.svelte used a single effectiveReadonly flag to gate both the issue-field editors (title, status, dates, description, dependencies, ...) AND the Panels comment composer below them. With the v6 split a Collaborator-Guest must be able to comment without unlocking the editors, so the two need separate variables. Adds canComment alongside effectiveReadonly: - effectiveReadonly stays driven by canEditIssueFields() — Guest gets read-only field editors. - canComment is driven by canCommentOnIssue() — Guest who is the Issues creator or listed as Collaborator gets the comment composer. Panels withoutInput now reads !canComment; all field-editor readonly props keep using effectiveReadonly. Step B.2. Signed-off-by: Michael Uray (cherry picked from commit 7449ad0d435c2eb43f12c8b1b41f31702eb6ac48) Signed-off-by: Michael Uray --- .../components/issues/edit/EditIssue.svelte | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/plugins/tracker-resources/src/components/issues/edit/EditIssue.svelte b/plugins/tracker-resources/src/components/issues/edit/EditIssue.svelte index bf7de0a5db4..2633a1d21ec 100644 --- a/plugins/tracker-resources/src/components/issues/edit/EditIssue.svelte +++ b/plugins/tracker-resources/src/components/issues/edit/EditIssue.svelte @@ -47,7 +47,7 @@ import { createEventDispatcher, onDestroy } from 'svelte' import { generateIssueShortLink, getIssueIdByIdentifier } from '../../../issues' - import { canEditIssue } from '../../../utils' + import { canCommentOnIssue, canEditIssueFields } from '../../../utils' import tracker from '../../../plugin' import IssueStatusActivity from '../IssueStatusActivity.svelte' import ControlPanel from './ControlPanel.svelte' @@ -73,16 +73,29 @@ let descriptionBox: AttachmentStyleBoxCollabEditor let showAllMixins: boolean + // Two independent gates: + // effectiveReadonly — Issue field editors (title, status, dates, ...). + // Driven by canEditIssueFields(). Guests are blocked. + // canComment — Comment composer at the bottom of the panel. + // Driven by canCommentOnIssue(). Guests who are + // Creator OR Collaborator on the issue are allowed. let effectiveReadonly = true + let canComment = false $: if (issue !== undefined) { const currentIssue = issue - void canEditIssue(currentIssue).then((canEdit) => { + void canEditIssueFields(currentIssue).then((canEdit) => { if (issue === currentIssue) { effectiveReadonly = readonly || !canEdit } }) + void canCommentOnIssue(currentIssue).then((v) => { + if (issue === currentIssue) { + canComment = !readonly && v + } + }) } else { effectiveReadonly = readonly + canComment = false } const inboxClient = InboxNotificationsClientImpl.getClient() @@ -211,7 +224,7 @@ Date: Mon, 25 May 2026 21:18:50 +0000 Subject: [PATCH 08/36] fix(chunter-resources): add @hcengineering/text-core dependency ChatMessageInput.svelte imports extractReferences from text-core for the mention warning popup. The package was already an indirect dep via text-editor-resources but webpack module resolution requires it as a direct entry in package.json. Added; pnpm-lock regenerated via rush update. Signed-off-by: Michael Uray (cherry picked from commit 7c58a562eeaaf54f94361e8a52cb9cb112fd2766) Signed-off-by: Michael Uray --- common/config/rush/pnpm-lock.yaml | 3 +++ plugins/chunter-resources/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 57a469ad1fa..35c7aa6b1d5 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -18056,6 +18056,9 @@ importers: '@hcengineering/text': specifier: workspace:^0.7.19 version: link:../../foundations/core/packages/text + '@hcengineering/text-core': + specifier: workspace:^0.7.0 + version: link:../../foundations/core/packages/text-core '@hcengineering/text-editor': specifier: workspace:^0.7.0 version: link:../text-editor diff --git a/plugins/chunter-resources/package.json b/plugins/chunter-resources/package.json index 05123872f19..64a71cfcf4a 100644 --- a/plugins/chunter-resources/package.json +++ b/plugins/chunter-resources/package.json @@ -57,6 +57,7 @@ "@hcengineering/preference": "workspace:^0.7.0", "@hcengineering/presentation": "workspace:^0.7.0", "@hcengineering/text": "workspace:^0.7.19", + "@hcengineering/text-core": "workspace:^0.7.0", "@hcengineering/ui": "workspace:^0.7.0", "@hcengineering/view": "workspace:^0.7.0", "@hcengineering/view-resources": "workspace:^0.7.0", From 961e33c43136b6cbd9788e2ac3e43e4b9126dd9f Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 21:00:14 +0000 Subject: [PATCH 09/36] feat(chunter-trigger): mention-grants-access uses shared helper + grant-target dedup Replaces the prior provideSecurity!==true guard with three explicit branches driven by resolveMentionGrantTarget(): 1. grantTarget non-null (opt-in class found via attachedTo chain): write Collaborator records on the grant-target Doc with dedup against the GRANT-TARGETs collaborator list, not the original message Docs list. Fixes the thread-reply case where the mention would otherwise land on the parent ChatMessage instead of the Issue. 2. grantTarget null + targetDoc not provideSecurity: keep todays notification-routing behavior on targetDoc (Channels, DMs). 3. grantTarget null + targetDoc provideSecurity (without opt-in): no-op. Preserves QMS / Love behavior bit-for-bit. Step B-2. Refs #10783, #9741. Signed-off-by: Michael Uray (cherry picked from commit ad78e4edb0b953883a904e50ea8adaab23aee1c1) Signed-off-by: Michael Uray --- server-plugins/chunter-resources/src/index.ts | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index 91896f6654c..582f7fc5baf 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -37,7 +37,9 @@ import core, { TxUpdateDoc, UserStatus, getClassCollaborators, - type MeasureContext + resolveMentionGrantTarget, + type MeasureContext, + type Collaborator } from '@hcengineering/core' import notification, { DocNotifyContext, NotificationContent } from '@hcengineering/notification' import { getMetadata, IntlString, translate } from '@hcengineering/platform' @@ -208,25 +210,64 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro } } - const classCollab = ( + // Resolve the Doc the mention-Collaborator records should land on. + // - targetDoc has provideSecurity:true && mentionsGrantAccess:true: + // helper returns targetDoc itself → grants access on that doc. + // - targetDoc unprotected, but its attachedTo chain reaches an opted-in + // ancestor (e.g. ThreadMessage → ChatMessage → Issue): helper returns + // that ancestor → grants access on the Issue, not the thread. + // - nothing in the chain is opted in: helper returns null. + // The grant-target branch writes Collaborator on the resolved doc and + // dedups against THAT doc's collaborator list (not against targetDoc's, + // which is the wrong basis when targetDoc is a child like ThreadMessage). + const grantTarget = await resolveMentionGrantTarget(targetDoc, (cls, q) => + control.findAll(control.ctx, cls, q) + ) + const targetClassCollab = ( await control.findAll(control.ctx, core.class.ClassCollaborators, { attachedTo: targetDoc._class }) )[0] - if (classCollab?.provideSecurity !== true) { + const isProtectedTarget = targetClassCollab?.provideSecurity === true + + if (grantTarget != null) { + const grantCollabs = ( + await control.findAll(control.ctx, core.class.Collaborator, { + attachedTo: grantTarget._id + }) + ).map((c) => c.collaborator) + + for (const collab of collaboratorsFromMessage) { + if (grantCollabs.includes(collab)) { + continue + } + res.push( + control.txFactory.createTxCreateDoc(core.class.Collaborator, grantTarget.space, { + attachedTo: grantTarget._id, + attachedToClass: grantTarget._class, + collaborator: collab, + collection: 'collaborators' + }) + ) + } + } else if (!isProtectedTarget) { + // Legacy notification-routing path: targetDoc is not provideSecurity, + // so Collaborator records here are purely for notification fan-out + // (today's behavior for Channels, DirectMessages, etc.). for (const collab of collaboratorsFromMessage) { if (currentCollaborators.includes(collab)) { continue } - - const tx = control.txFactory.createTxCreateDoc(core.class.Collaborator, targetDoc.space, { - attachedTo: targetDoc._id, - attachedToClass: targetDoc._class, - collaborator: collab, - collection: 'collaborators' - }) - - res.push(tx) + res.push( + control.txFactory.createTxCreateDoc(core.class.Collaborator, targetDoc.space, { + attachedTo: targetDoc._id, + attachedToClass: targetDoc._class, + collaborator: collab, + collection: 'collaborators' + }) + ) } } + // Else: protected target without mentionsGrantAccess (QMS / Love today) + // → no-op, preserving pre-PR behavior for those classes. if (account != null && isChannel && !(targetDoc as Channel).members.includes(account)) { res.push(...joinChannel(control, targetDoc as Channel, account)) From d886ee0aabc4df1aaf27ff1c96ad88aca4898d1b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 21:03:38 +0000 Subject: [PATCH 10/36] feat(chunter/ChatMessageInput): warn before mention auto-grants Issue access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disclosure UX for the mention-grants-access flow. Before submitting a message containing @-mentions, check whether the resolved grant-target Doc (via the shared resolveMentionGrantTarget helper) has those mentioned users in its space.members list. If any mention would grant new access, show a MessageBox warning with the actor and the names of the new grantees, and require explicit confirmation. Important framing: - This is disclosure UX for the standard client, NOT a security gate. Access is enforced server-side by SpaceSecurityMiddleware + the chunter trigger that creates Collaborator records. A scripted API client can still bypass the dialog. - For thread replies: the grant-target resolves to the root Issue, so the warning correctly names the project the user would gain access to (not the thread). - For Channels and other non-opted-in classes: grantTarget is null, no warning shown — preserves todays behavior bit-for-bit. Step C. Refs #10783, #9741. Signed-off-by: Michael Uray (cherry picked from commit ab212dbf61a828334ceb88ca470466b8c40d3fdd) Signed-off-by: Michael Uray --- .../chat-message/ChatMessageInput.svelte | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index 4f9c51ed2c4..9abc5218196 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -17,12 +17,25 @@ import { Analytics } from '@hcengineering/analytics' import { AttachmentRefInput } from '@hcengineering/attachment-resources' import chunter, { ChatMessage, ChunterEvents, ThreadMessage } from '@hcengineering/chunter' - import { Class, Doc, generateId, getCurrentAccount, Ref, type CommitResult } from '@hcengineering/core' - import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation' - import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' + import contact, { type Person } from '@hcengineering/contact' + import core, { + type AccountUuid, + Class, + Doc, + generateId, + getCurrentAccount, + Ref, + resolveMentionGrantTarget, + type Space, + type CommitResult + } from '@hcengineering/core' + import { getEmbeddedLabel } from '@hcengineering/platform' + import { createQuery, DraftController, draftsStore, getClient, MessageBox } from '@hcengineering/presentation' + import { EmptyMarkup, isEmptyMarkup, markupToJSON } from '@hcengineering/text' + import { extractReferences } from '@hcengineering/text-core' import { createEventDispatcher } from 'svelte' import { getObjectId } from '@hcengineering/view-resources' - import { ThrottledCaller } from '@hcengineering/ui' + import { showPopup, ThrottledCaller } from '@hcengineering/ui' import { getSpace, editingMessageStore } from '@hcengineering/activity-resources' import { getChannelSpace } from '../../utils' @@ -126,8 +139,78 @@ currentMessage.attachments = attachments } + /** + * Disclosure UX for the mention-grants-access flow. If the message + * mentions a Person whose AccountUuid is NOT already in the resolved + * grant-target's space.members, show a confirmation dialog before + * submitting. The actual access grant happens server-side via the + * chunter trigger (Collaborator record) + SpaceSecurity middleware + * (provideSecurity OR-branch). This dialog only surfaces the fact + * to the actor — a scripted API client could still bypass it. + * + * Returns true if the message should be sent, false to cancel. + */ + async function confirmMentionGrantsAccess (markup: string): Promise { + if (markup === undefined || markup === '' || isEmptyMarkup(markup)) return true + let node + try { + node = markupToJSON(markup) + } catch { + return true + } + const references = extractReferences(node) + const mentionedPersonIds = references + .filter(({ objectClass }) => hierarchy.isDerived(objectClass, contact.class.Person)) + .map(({ objectId }) => objectId as Ref) + if (mentionedPersonIds.length === 0) return true + + const grantTarget = await resolveMentionGrantTarget(object, (cls, q) => client.findAll(cls, q)) + if (grantTarget == null) return true + + const space = (await client.findAll(core.class.Space, { _id: grantTarget.space }))[0] + if (space === undefined) return true + const members = new Set(space.members ?? []) + + const persons = await client.findAll(contact.class.Person, { _id: { $in: mentionedPersonIds } }) + const newGrantees = persons.filter( + (p) => p.personUuid != null && !members.has(p.personUuid as AccountUuid) + ) + if (newGrantees.length === 0) return true + + const names = newGrantees.map((p) => `${p.name ?? p.personUuid}`).join(', ') + const targetName: string = + (grantTarget as any).name ?? (grantTarget as any).title ?? grantTarget._id + const spaceName: string = (space as any).name ?? space._id + + return await new Promise((resolve) => { + showPopup( + MessageBox, + { + label: getEmbeddedLabel('Heads up — this mention grants access'), + message: getEmbeddedLabel( + `${names} ${ + newGrantees.length === 1 ? 'is not a member' : 'are not members' + } of "${spaceName}". Sending this comment will grant ${ + newGrantees.length === 1 ? 'them' : 'them' + } read access to "${targetName}" and the ability to post comments on it. They will not be able to edit the document's fields. This is enforced by server policy; if you cancel, no access is granted.` + ), + okLabel: getEmbeddedLabel('Send and grant access'), + dangerous: false, + canSubmit: true + }, + undefined, + (res?: boolean) => { + resolve(res === true) + } + ) + }) + } + async function handleCreate (event: CustomEvent, _id: Ref): Promise { try { + const proceed = await confirmMentionGrantsAccess(event.detail?.message ?? '') + if (!proceed) return + const res = await createMessage(event, _id, `chunter.create.${_class} ${object._class}`) console.log(`create.${_class} measure`, res.serverTime, res.time) From f5157a078211a64cb4ab6827ae37efa9d00f689b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 21:52:44 +0000 Subject: [PATCH 11/36] feat(middleware/spaceSecurity): let Guests read collab-secured docs across spaces SpaceSecurityMiddleware.findAll() pre-filtered every query by the caller's allowed-spaces set, which always strips docs in foreign spaces before the database adapter ever sees the query. That defeated the Postgres adapter's `collabRes` OR-branch (see postgres/src/storage.ts, getSecurityClause): a Guest who is a Collaborator on a single Issue in a project they are not a member of would never receive that Issue, even though the adapter has the SQL to surface it. This change skips the middleware-level space filter when: - the target class has ClassCollaborators.provideSecurity === true or provideAttachedSecurity === true, AND - the caller's role is Guest or ReadOnlyGuest. For those calls the Postgres adapter still applies its space-membership clause and additionally OR-joins Collaborator records, giving the user visibility on the individual docs they were added to (directly via provideSecurity, or via their parent via provideAttachedSecurity for ActivityMessage / DocUpdateMessage). All other roles (User, Maintainer, Owner, Admin, DocGuest, System) take the existing code path unchanged. Note for non-Postgres adapters: the Mongo adapter does not currently implement the collab OR-branch, so this bypass only takes effect on Postgres deployments (the supported production target). On Mongo the visibility behavior is unchanged from before because there was no collab OR-clause to fall through to. Signed-off-by: Michael Uray (cherry picked from commit 5db91841cae1fc9bdad898229e8b213f72a72c6b) Signed-off-by: Michael Uray --- .../packages/middleware/src/spaceSecurity.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 4c8bd4223e1..985f47f2180 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -630,7 +630,26 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar let clientFilterSpaces: Set> | undefined - if (!isSystem(account, ctx) && account.role !== AccountRole.DocGuest && domain !== DOMAIN_MODEL) { + // When a class opts into collaborator-grants-read security AND the caller is a Guest/ReadOnlyGuest, + // we deliberately skip the middleware-level space filter. The Postgres adapter's `collabRes` + // OR-branch (see postgres/src/storage.ts, getSecurityClause) joins Collaborator records into the + // visibility check, so Guests can read individual docs they were added to as Collaborator even + // when they are not members of the owning Space. Filtering by space here would strip those docs + // before the adapter ever sees the query. + const collabSec = + domain !== DOMAIN_MODEL + ? getClassCollaborators(this.context.modelDb, this.context.hierarchy, _class) + : undefined + const collabReadBypass = + (collabSec?.provideSecurity === true || collabSec?.provideAttachedSecurity === true) && + [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role) + + if ( + !isSystem(account, ctx) && + account.role !== AccountRole.DocGuest && + domain !== DOMAIN_MODEL && + !collabReadBypass + ) { if (!isOwner(account, ctx) || !isSpace || !showArchived) { if (newQuery[field] !== undefined) { const res = await this.mergeQuery(ctx, account, newQuery[field], domain, isSpace, showArchived) From e6f6fcfb7551ce8f0aea75d1dbd7ce4438be139a Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 22:05:53 +0000 Subject: [PATCH 12/36] feat(security): allow non-System callers to read their own Collaborator records The tracker "Subscribed" tab in the client queries `findAll(Collaborator, { collaborator: self, attachedToClass: Issue })` to build the user's subscription list. Until now that query was silently filtered to spaces the user is a member of, so a Guest who became a Collaborator on an Issue in a project they are not a member of (e.g. via the new mentions-grant-access flow) saw an empty Subscribed list. This change introduces a self-Collaborator visibility rule consistent across the two enforcement layers: - foundations/server/packages/postgres/src/storage.ts `addSecurity` now appends `OR .collaborator = ''` whenever the queried domain is DOMAIN_COLLABORATOR. The user can always see their own Collaborator rows. - foundations/server/packages/middleware/src/spaceSecurity.ts `findAll` skips its space pre-filter when the requested class is core.class.Collaborator (`selfCollabBypass`), so the Postgres adapter actually receives the query and can apply its self-row OR-branch. Other rows in the same workspace remain hidden by the space-membership clause that still runs first. Scope: applies to all non-Admin/non-System callers (Users included). Users were not visibly affected before because they are typically members of every space whose docs they collaborate on, but the rule is the same: you may read your own subscription rows. Signed-off-by: Michael Uray (cherry picked from commit 51fa7ba162a7d1df7ed382c50d915e4fde4fab68) Signed-off-by: Michael Uray --- .../server/packages/middleware/src/spaceSecurity.ts | 9 ++++++++- foundations/server/packages/postgres/src/storage.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 985f47f2180..454dfed745b 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -643,12 +643,19 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar const collabReadBypass = (collabSec?.provideSecurity === true || collabSec?.provideAttachedSecurity === true) && [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role) + // Self-Collaborator visibility: let the Postgres adapter's self-collab OR-branch + // (storage.ts, addSecurity) fire for any non-System caller when reading the + // Collaborator class itself. Required for queries like the tracker "Subscribed" + // tab `{collaborator: self, attachedToClass: Issue}`, which must surface the + // user's own subscriptions even on docs in non-member spaces. + const selfCollabBypass = this.context.hierarchy.isDerived(_class, core.class.Collaborator) if ( !isSystem(account, ctx) && account.role !== AccountRole.DocGuest && domain !== DOMAIN_MODEL && - !collabReadBypass + !collabReadBypass && + !selfCollabBypass ) { if (!isOwner(account, ctx) || !isSpace || !showArchived) { if (newQuery[field] !== undefined) { diff --git a/foundations/server/packages/postgres/src/storage.ts b/foundations/server/packages/postgres/src/storage.ts index c2fec296227..413b19632a3 100644 --- a/foundations/server/packages/postgres/src/storage.ts +++ b/foundations/server/packages/postgres/src/storage.ts @@ -647,6 +647,14 @@ abstract class PostgresAdapterBase implements DbAdapter { collabRes += ` OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} collab_sec WHERE collab_sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND collab_sec."attachedTo" = ${domain}."attachedTo" AND collab_sec.collaborator = '${acc.uuid}')` } } + // Self-Collaborator visibility: any non-Admin/non-System caller can always read + // their own Collaborator records, regardless of space membership. Without this, + // a Guest who is a Collaborator on a doc in a project they are not a member of + // could never enumerate their own subscriptions (e.g. the tracker "Subscribed" + // tab queries Collaborator by `{collaborator: self}`). + if (domain === DOMAIN_COLLABORATOR) { + collabRes += ` OR ${domain}.collaborator = '${acc.uuid}'` + } return `AND (${res}${collabRes})` } } From bc25a70d8f3e3e7a557f41a64196c764a401374b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Mon, 25 May 2026 22:43:52 +0000 Subject: [PATCH 13/36] feat(security+nav): list collab-only spaces in workbench navigator for Guests A Guest who is a Collaborator on a doc inside a Space they are not a member of (e.g. via the new mentions-grant-access flow) could open the doc directly via URL and see it listed in tracker "Subscribed", but the containing project never appeared in the "Your Projects" nav tree because that tree only listed member-spaces. This change extends visibility on three layers: - foundations/server/packages/postgres/src/storage.ts `addSecurity` adds an `OR EXISTS (collaborator c WHERE c.space = space._id AND c.collaborator = '')` branch for Guest/ ReadOnlyGuest reads against DOMAIN_SPACE. A Space hosting any Collaborator record naming the caller becomes readable. - foundations/server/packages/middleware/src/spaceSecurity.ts `findAll` skips its space pre-filter when the target class is a Space and the caller is Guest/ReadOnlyGuest (`spaceCollabBypass`), so the new Postgres OR-branch actually fires. - plugins/workbench-resources/src/components/Navigator.svelte A second query against Collaborator (scoped to self by A6's self- collab visibility rule) collects the unique `space` IDs of the caller's Collaborator records, fetches those Spaces narrowed to the navigator's class set, and merges them into the displayed spaces alongside member-spaces. Existing member-spaces semantics for User+/Admin accounts are unchanged: the second query is skipped for admins, and `members: ` stays on the primary query for everyone, so public-but-not-member projects are not surfaced as a side effect. Clicking a collab-only project still opens the existing Issues view, which now shows only the docs the user can actually see (member-or- collab-on-doc) thanks to the earlier A5/A6 work. Components, Milestones and Templates sub-nodes will show empty for collab-only projects. Signed-off-by: Michael Uray (cherry picked from commit 64105d1f14bed4ef18a330861937273685fcc21c) Signed-off-by: Michael Uray --- .../packages/middleware/src/spaceSecurity.ts | 9 ++- .../server/packages/postgres/src/storage.ts | 10 ++++ .../src/components/Navigator.svelte | 59 ++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/foundations/server/packages/middleware/src/spaceSecurity.ts b/foundations/server/packages/middleware/src/spaceSecurity.ts index 454dfed745b..2676b6e1590 100644 --- a/foundations/server/packages/middleware/src/spaceSecurity.ts +++ b/foundations/server/packages/middleware/src/spaceSecurity.ts @@ -649,13 +649,20 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar // tab `{collaborator: self, attachedToClass: Issue}`, which must surface the // user's own subscriptions even on docs in non-member spaces. const selfCollabBypass = this.context.hierarchy.isDerived(_class, core.class.Collaborator) + // Containing-Space visibility for collab-only Guests: let the Postgres adapter's + // space-collab OR-branch surface Spaces that host docs the caller is a + // Collaborator on. Required so the project/space nav tree can list projects + // where the user is collab-only (no member status). + const spaceCollabBypass = + isSpace && [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role) if ( !isSystem(account, ctx) && account.role !== AccountRole.DocGuest && domain !== DOMAIN_MODEL && !collabReadBypass && - !selfCollabBypass + !selfCollabBypass && + !spaceCollabBypass ) { if (!isOwner(account, ctx) || !isSpace || !showArchived) { if (newQuery[field] !== undefined) { diff --git a/foundations/server/packages/postgres/src/storage.ts b/foundations/server/packages/postgres/src/storage.ts index 413b19632a3..4ada23e60df 100644 --- a/foundations/server/packages/postgres/src/storage.ts +++ b/foundations/server/packages/postgres/src/storage.ts @@ -655,6 +655,16 @@ abstract class PostgresAdapterBase implements DbAdapter { if (domain === DOMAIN_COLLABORATOR) { collabRes += ` OR ${domain}.collaborator = '${acc.uuid}'` } + // Containing-Space visibility for collab-only Guests: surface Spaces that + // host docs the caller is a Collaborator on. Required for the project/space + // nav tree to list such projects (and for any code resolving the doc's + // parent space to succeed). The Collaborator record's `space` field always + // mirrors the parent doc's `space`, so existence of any such record naming + // the caller is sufficient evidence that the Space contains something they + // can see. + if (domain === DOMAIN_SPACE && [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role)) { + collabRes += ` OR EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_COLLABORATOR)} space_collab WHERE space_collab."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND space_collab.space = ${domain}._id AND space_collab.collaborator = '${acc.uuid}')` + } return `AND (${res}${collabRes})` } } diff --git a/plugins/workbench-resources/src/components/Navigator.svelte b/plugins/workbench-resources/src/components/Navigator.svelte index ca09ae04e37..d76a7c290b9 100644 --- a/plugins/workbench-resources/src/components/Navigator.svelte +++ b/plugins/workbench-resources/src/components/Navigator.svelte @@ -35,17 +35,33 @@ const client = getClient() const hierarchy = client.getHierarchy() const query = createQuery() + // Collab-spaces: spaces that host docs the current account is a Collaborator on, + // even when the account is not in the space's `members`. Powers nav-tree visibility + // for collab-only Guests on classes that opted into ClassCollaborators.provideSecurity. + const collabSpaceLookupQuery = createQuery() + const collabSpaceQuery = createQuery() - let spaces: Space[] = [] + let memberSpaces: Space[] = [] + let collabSpaces: Space[] = [] + let collabSpaceIds: Set> = new Set>() let starred: Space[] = [] let shownSpaces: Space[] = [] const adminUser = isAdminUser() + let activeClasses: Ref[] = [] + + $: spaces = adminUser || collabSpaces.length === 0 + ? memberSpaces + : (() => { + const seen = new Set(memberSpaces.map((s) => s._id)) + return [...memberSpaces, ...collabSpaces.filter((s) => !seen.has(s._id))] + })() $: if (model) { const classes = Array.from(new Set(getSpecialSpaceClass(model).flatMap((c) => hierarchy.getDescendants(c)))).filter( (it) => !hierarchy.isMixin(it) ) + activeClasses = classes as Ref[] if (classes.length > 0) { query.query( classes.length === 1 ? classes[0] : core.class.Space, @@ -56,15 +72,54 @@ } : { ...(classes.length === 1 ? {} : { _class: { $in: classes } }) }, (result) => { - spaces = result + memberSpaces = result }, { sort: { name: SortingOrder.Ascending } } ) } else { query.unsubscribe() + memberSpaces = [] } } + // Track every Space that hosts a Collaborator record naming the current account. + // We track unconditionally (cheap query, scoped to self by A6) and let the second + // query narrow by the navigator's class set. + $: if (!adminUser) { + collabSpaceLookupQuery.query( + core.class.Collaborator, + { collaborator: getCurrentAccount().uuid }, + (collabs) => { + const next = new Set>(collabs.map((c) => c.space)) + if (next.size !== collabSpaceIds.size || !Array.from(next).every((id) => collabSpaceIds.has(id))) { + collabSpaceIds = next + } + }, + { projection: { space: 1 } } + ) + } else { + collabSpaceLookupQuery.unsubscribe() + collabSpaceIds = new Set>() + } + + $: if (!adminUser && collabSpaceIds.size > 0 && activeClasses.length > 0) { + const classes = activeClasses + collabSpaceQuery.query( + classes.length === 1 ? classes[0] : core.class.Space, + { + _id: { $in: Array.from(collabSpaceIds) }, + ...(classes.length === 1 ? {} : { _class: { $in: classes } }) + }, + (result) => { + collabSpaces = result + }, + { sort: { name: SortingOrder.Ascending } } + ) + } else { + collabSpaceQuery.unsubscribe() + collabSpaces = [] + } + let specials: SpecialNavModel[] = [] let preferences: Map, SpacePreference> = new Map, SpacePreference>() From 30ad68df7662aa47ad0521808e3da4689acaee67 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Tue, 26 May 2026 02:59:11 +0000 Subject: [PATCH 14/36] fix(nav): surface collab-only projects in tracker nav-tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workbench Navigator already fetched collab-only Spaces and merged them into the spaces array, but each SpacesNav then ran its `visibleIf` resource filter against the entries — and tracker's `IsProjectJoined` only returned true for members. The result was that collab-only projects were correctly fetched, hashed and passed to the component, then immediately filtered back out before render. Changes: - plugins/workbench-resources/src/components/Navigator.svelte Refactor activeClasses into a top-level reactive declaration so Svelte tracks it as a dependency in the downstream collab-space query. Drop the diagnostic console.log lines that helped track this down. - plugins/tracker-resources/src/index.ts Extend IsProjectJoined: return true also when the caller has any Collaborator record attached to a doc inside this project. Reuses A6 self-collaborator visibility so the lookup works for Guests that are not space members. Verified end-to-end against the dk3 test workspace: Florian (Guest, collab on GAME-4 only) now sees the Game Design project in his Your Projects tree, with the Issues special filtered down to only the docs he is actually a Collaborator on. Components / Milestones / Templates remain empty for the same caller. Signed-off-by: Michael Uray (cherry picked from commit 97b58bd5063bb690211e8bc40fbf024cb7e00aae) Signed-off-by: Michael Uray --- plugins/tracker-resources/src/index.ts | 16 ++++++- .../src/components/Navigator.svelte | 46 +++++++++---------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 071a13ac73f..0f0a7cf1b72 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -14,7 +14,7 @@ // import { Analytics } from '@hcengineering/analytics' -import { +import core, { type Attribute, type Class, type Client, @@ -517,7 +517,19 @@ export default async (): Promise => ({ ) => await getAllStates(query, onUpdate, queryId, attr, false), GetVisibleFilters: getVisibleFilters, IssueChatTitleProvider: getIssueChatTitle, - IsProjectJoined: async (project: Project) => project.members.includes(getCurrentAccount().uuid), + IsProjectJoined: async (project: Project) => { + const accountUuid = getCurrentAccount().uuid + if (project.members.includes(accountUuid)) return true + // Collab-only access: surface projects in the nav tree when the user is a + // Collaborator on any doc inside this project, even without space-membership. + // Backend security (postgres collabRes OR-branch + middleware bypass) limits + // what the user can actually open within the project. + const collab = await getClient().findOne(core.class.Collaborator, { + collaborator: accountUuid, + space: project._id + }) + return collab !== undefined + }, GetIssueStatusCategories: getIssueStatusCategories, SetComponentStore: setStore, ComponentFilterFunction: filterComponents, diff --git a/plugins/workbench-resources/src/components/Navigator.svelte b/plugins/workbench-resources/src/components/Navigator.svelte index d76a7c290b9..8be6f5d98c7 100644 --- a/plugins/workbench-resources/src/components/Navigator.svelte +++ b/plugins/workbench-resources/src/components/Navigator.svelte @@ -48,7 +48,12 @@ let shownSpaces: Space[] = [] const adminUser = isAdminUser() - let activeClasses: Ref[] = [] + + $: activeClasses = (model + ? Array.from(new Set(getSpecialSpaceClass(model).flatMap((c) => hierarchy.getDescendants(c)))).filter( + (it) => !hierarchy.isMixin(it) + ) + : []) as Ref[] $: spaces = adminUser || collabSpaces.length === 0 ? memberSpaces @@ -57,29 +62,24 @@ return [...memberSpaces, ...collabSpaces.filter((s) => !seen.has(s._id))] })() - $: if (model) { - const classes = Array.from(new Set(getSpecialSpaceClass(model).flatMap((c) => hierarchy.getDescendants(c)))).filter( - (it) => !hierarchy.isMixin(it) + $: if (model && activeClasses.length > 0) { + const classes = activeClasses + query.query( + classes.length === 1 ? classes[0] : core.class.Space, + !adminUser + ? { + ...(classes.length === 1 ? {} : { _class: { $in: classes } }), + members: getCurrentAccount().uuid + } + : { ...(classes.length === 1 ? {} : { _class: { $in: classes } }) }, + (result) => { + memberSpaces = result + }, + { sort: { name: SortingOrder.Ascending } } ) - activeClasses = classes as Ref[] - if (classes.length > 0) { - query.query( - classes.length === 1 ? classes[0] : core.class.Space, - !adminUser - ? { - ...(classes.length === 1 ? {} : { _class: { $in: classes } }), - members: getCurrentAccount().uuid - } - : { ...(classes.length === 1 ? {} : { _class: { $in: classes } }) }, - (result) => { - memberSpaces = result - }, - { sort: { name: SortingOrder.Ascending } } - ) - } else { - query.unsubscribe() - memberSpaces = [] - } + } else if (model) { + query.unsubscribe() + memberSpaces = [] } // Track every Space that hosts a Collaborator record naming the current account. From 2c916a23776bc58e0949e59cf1afe9e78faabecd Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Tue, 26 May 2026 05:37:06 +0000 Subject: [PATCH 15/36] fix(tracker/nav): hide non-Issues sub-nodes on non-member projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A9 follow-up to the mention-grants nav-tree work. When a Guest sees a project in 'Your Projects' because they are a Collaborator on a doc inside it (A7) — or any non-member who lands a project in the tree via the new IsProjectJoined fallback (A8) — only the Issues sub-node has anything to render. Components, Milestones, and Templates have no provideSecurity opt-in, so the postgres collab-OR-branch does not fire on them and the queries come back empty. The user sees three silent dead-end entries. Fix in ProjectSpacePresenter: derive isCollabOnlyProject from space.members membership and filter the specials down to id == 'issues' when the caller is not a member. Members keep all four sub-nodes; the visibleIf chain for collab-only projects keeps Issues and nothing else. This is a UI-only narrowing. Backend visibility is unchanged: A5/A6/A7 still control what the user can actually see when they navigate elsewhere, the postgres adapter still does its filter, and the middleware bypass remains scoped to provideSecurity classes. If a future class opts into provideSecurity / provideAttachedSecurity for a project sub-collection (Components etc), the filter here can be relaxed without touching the underlying security model. Signed-off-by: Michael Uray (cherry picked from commit c76073c0bf01601bd76150626005cae33a51bf88) Signed-off-by: Michael Uray --- .../projects/ProjectSpacePresenter.svelte | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte b/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte index 17f004068b6..20d90a035bd 100644 --- a/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte +++ b/plugins/tracker-resources/src/components/projects/ProjectSpacePresenter.svelte @@ -13,7 +13,7 @@ // limitations under the License. --> {#if specials} - getActions(space)} - {forciblyСollapsed} - > - {#each specials as special} - - - - {/each} - - - {#if visible} - {@const item = specials.find((sp) => sp.id === currentSpecial && currentSpace === space._id)} - {#if item} - - + {#if isCollabOnlyProject} +
+ getActions(space)} + {forciblyСollapsed} + > + {#each specials as special} + + + {/each} + + + {#if visible} + {@const item = specials.find((sp) => sp.id === currentSpecial && currentSpace === space._id)} + {#if item} + + + + {/if} + {/if} + + +
+ {:else} + getActions(space)} + {forciblyСollapsed} + > + {#each specials as special} + + + + {/each} + + + {#if visible} + {@const item = specials.find((sp) => sp.id === currentSpecial && currentSpace === space._id)} + {#if item} + + + + {/if} {/if} - {/if} - - +
+
+ {/if} {/if} diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index eed088c59d5..dadcd1249b6 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -307,7 +307,8 @@ export default mergeIds(trackerId, tracker, { UnsetParent: '' as IntlString, PreviousAssigned: '' as IntlString, EditRelatedTargets: '' as IntlString, - RelatedIssueTargetDescription: '' as IntlString + RelatedIssueTargetDescription: '' as IntlString, + SharedWithYouTooltip: '' as IntlString }, component: { NopeComponent: '' as AnyComponent, From 8069e28572f380ea2a99fb1053712a1ff724cb28 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:09:44 +0000 Subject: [PATCH 17/36] test(tracker): V2 - mention-grant persists after issue moves to Done Regression guard: a user mentioned in an issue comment stays a Collaborator (the record that backs read access) after the issue is moved to Done. Built on the sanity page-object helpers, mirroring the existing mentions.spec assertion style. Guest-perspective UI walk is a documented follow-up (needs a second auth storage state). Signed-off-by: Michael Uray (cherry picked from commit 27d2c58103cc5e530fba3af851a6426081400a42) Signed-off-by: Michael Uray --- .../mention-grants-closed-issue.spec.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/sanity/tests/tracker/mention-grants-closed-issue.spec.ts diff --git a/tests/sanity/tests/tracker/mention-grants-closed-issue.spec.ts b/tests/sanity/tests/tracker/mention-grants-closed-issue.spec.ts new file mode 100644 index 00000000000..73f1a5646dc --- /dev/null +++ b/tests/sanity/tests/tracker/mention-grants-closed-issue.spec.ts @@ -0,0 +1,63 @@ +import { test } from '@playwright/test' +import { generateId, PlatformSetting, PlatformURI } from '../utils' +import { IssuesPage } from '../model/tracker/issues-page' +import { IssuesDetailsPage } from '../model/tracker/issues-details-page' +import { TrackerNavigationMenuPage } from '../model/tracker/tracker-navigation-menu-page' +import { NewIssue } from '../model/tracker/types' + +test.use({ + storageState: PlatformSetting +}) + +test.describe('mention-grants — closed issue access', () => { + let trackerNav: TrackerNavigationMenuPage + let issuesPage: IssuesPage + let issuesDetailsPage: IssuesDetailsPage + + test.beforeEach(async ({ page }) => { + trackerNav = new TrackerNavigationMenuPage(page) + issuesPage = new IssuesPage(page) + issuesDetailsPage = new IssuesDetailsPage(page) + + await (await page.goto(`${PlatformURI}/workbench/sanity-ws`))?.finished() + }) + + test('mentioned user remains a Collaborator after the issue is moved to Done', async ({ page }) => { + const issue: NewIssue = { + title: `V2 closed-issue mention-grant-${generateId()}`, + description: 'Mention a user, then close the issue; the mention-grant must persist.' + } + + // Navigate to the Default project's issues list and show all issues. + // openIssuesForProject + clickModelSelectorAll mirrors the pattern used + // by issues-duplicate.spec.ts and attachments.spec.ts. + await trackerNav.openIssuesForProject('Default') + await issuesPage.clickModelSelectorAll() + + await issuesPage.createNewIssue(issue) + await issuesPage.searchIssueByName(issue.title) + await issuesPage.openIssueByName(issue.title) + + // Mention a user via the real mention picker (Dirak Kainin is the seed + // contact used by the existing mentions.spec.ts:37). addMentions() opens + // the @-popup and selects the match — addComment() would only send literal + // text and would NOT create a reference node. + await issuesDetailsPage.addMentions('Dirak Kainin') + + // Move the issue to Done. editIssue accepts a partial Issue; the status + // field clicks the status button and selects from the dropdown. + await issuesDetailsPage.editIssue({ status: 'Done' }) + + // Reload and confirm the mentioned user is listed under Collaborators. + // The issue author ('Appleseed John') and the mentioned user ('Dirak Kainin') + // must both be present — this mirrors the assertion in mentions.spec.ts:40. + await page.reload() + await issuesDetailsPage.checkCollaborators(['Appleseed John', 'Dirak Kainin']) + }) +}) + +// TODO (V2b): add a guest storage state and assert the issue is visible + +// commentable from the guest's own session. The above test covers the +// server-side grant (Collaborator record exists) which is the actual +// regression risk; the guest-perspective UI walk needs a second authenticated +// Playwright storage state. From dc3e380c298b4b3fec1614363c91f1c8a38ce1dd Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:13:45 +0000 Subject: [PATCH 18/36] feat(text-core): V3a - grantsAccess attr on mention references Add a string grantsAccess ('true'|'false'|undefined) to ReferenceMarkupNode and surface it on extractReferences() with any-wins dedup (a person is denied only if every reference to them is explicitly 'false'). undefined preserves pre-V3 grant-by-default. Tiptap ReferenceNode keeps the data-grants-access attribute across editor round-trips. Signed-off-by: Michael Uray (cherry picked from commit e21951022c909236113ade6aa75498b204fa595d) Signed-off-by: Michael Uray --- .../src/markup/__tests__/reference.test.ts | 80 +++++++++++++++++++ .../packages/text-core/src/markup/model.ts | 8 +- .../text-core/src/markup/reference.ts | 15 +++- .../core/packages/text/src/nodes/reference.ts | 10 +++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 foundations/core/packages/text-core/src/markup/__tests__/reference.test.ts diff --git a/foundations/core/packages/text-core/src/markup/__tests__/reference.test.ts b/foundations/core/packages/text-core/src/markup/__tests__/reference.test.ts new file mode 100644 index 00000000000..21479d166c2 --- /dev/null +++ b/foundations/core/packages/text-core/src/markup/__tests__/reference.test.ts @@ -0,0 +1,80 @@ +import { extractReferences } from '../reference' +import { MarkupNodeType, type MarkupNode } from '../model' + +function refNode (id: string, label: string, objectclass: string, grantsAccess?: 'true' | 'false'): MarkupNode { + return { + type: MarkupNodeType.reference, + attrs: { id, label, objectclass, ...(grantsAccess !== undefined ? { grantsAccess } : {}) } + } +} + +function doc (...children: MarkupNode[]): MarkupNode { + return { type: MarkupNodeType.doc, content: children } +} + +describe('extractReferences grantsAccess', () => { + test('surfaces grantsAccess from a reference node', () => { + const refs = extractReferences(doc(refNode('p1', 'Alice', 'contact:class:Person', 'true'))) + expect(refs).toHaveLength(1) + expect(refs[0].objectId).toBe('p1') + expect(refs[0].grantsAccess).toBe('true') + }) + + test('grantsAccess undefined when attr absent (default = grant)', () => { + const refs = extractReferences(doc(refNode('p1', 'Alice', 'contact:class:Person'))) + expect(refs[0].grantsAccess).toBeUndefined() + }) + + test('any-wins: false then true => true', () => { + const refs = extractReferences( + doc( + refNode('p1', 'Alice', 'contact:class:Person', 'false'), + refNode('p1', 'Alice', 'contact:class:Person', 'true') + ) + ) + expect(refs).toHaveLength(1) + expect(refs[0].grantsAccess).toBe('true') + }) + + test('any-wins: true then false => true', () => { + const refs = extractReferences( + doc( + refNode('p1', 'Alice', 'contact:class:Person', 'true'), + refNode('p1', 'Alice', 'contact:class:Person', 'false') + ) + ) + expect(refs[0].grantsAccess).toBe('true') + }) + + test('any-wins: false then undefined => not false (undefined grants)', () => { + const refs = extractReferences( + doc( + refNode('p1', 'Alice', 'contact:class:Person', 'false'), + refNode('p1', 'Alice', 'contact:class:Person') + ) + ) + expect(refs[0].grantsAccess).not.toBe('false') + }) + + test('all explicitly false => false', () => { + const refs = extractReferences( + doc( + refNode('p1', 'Alice', 'contact:class:Person', 'false'), + refNode('p1', 'Alice', 'contact:class:Person', 'false') + ) + ) + expect(refs[0].grantsAccess).toBe('false') + }) + + test('different persons kept separate', () => { + const refs = extractReferences( + doc( + refNode('p1', 'Alice', 'contact:class:Person', 'true'), + refNode('p2', 'Bob', 'contact:class:Person', 'false') + ) + ) + expect(refs).toHaveLength(2) + expect(refs.find((r) => r.objectId === 'p1')?.grantsAccess).toBe('true') + expect(refs.find((r) => r.objectId === 'p2')?.grantsAccess).toBe('false') + }) +}) diff --git a/foundations/core/packages/text-core/src/markup/model.ts b/foundations/core/packages/text-core/src/markup/model.ts index 9b275c14f0c..cc5b581914a 100644 --- a/foundations/core/packages/text-core/src/markup/model.ts +++ b/foundations/core/packages/text-core/src/markup/model.ts @@ -93,5 +93,11 @@ export interface LinkMark extends MarkupMark { /** @public */ export interface ReferenceMarkupNode extends MarkupNode { type: MarkupNodeType.reference - attrs: { id: string, label: string, objectclass: string } + /** + * grantsAccess (V3): when 'false', the chunter mention-grants trigger skips + * Collaborator creation for this mention. undefined means "grant" (default, + * preserves pre-V3 behaviour). Stored as a string because markup attrs + * serialize through HTML where boolean coercion is unreliable. + */ + attrs: { id: string, label: string, objectclass: string, grantsAccess?: 'true' | 'false' } } diff --git a/foundations/core/packages/text-core/src/markup/reference.ts b/foundations/core/packages/text-core/src/markup/reference.ts index a9e4c64261a..cccf9f35025 100644 --- a/foundations/core/packages/text-core/src/markup/reference.ts +++ b/foundations/core/packages/text-core/src/markup/reference.ts @@ -24,6 +24,9 @@ export interface Reference { objectId: Ref objectClass: Ref> parentNode: MarkupNode | null + // V3: undefined = grant (default); 'false' = explicit deny. Merged any-wins + // across duplicate references to the same object (see extractReferences). + grantsAccess?: 'true' | 'false' } /** @@ -37,9 +40,19 @@ export function extractReferences (content: MarkupNode): Array { const reference = node as ReferenceMarkupNode const objectId = reference.attrs.id as Ref const objectClass = reference.attrs.objectclass as Ref> + const grantsAccess = reference.attrs.grantsAccess const e = result.find((e) => e.objectId === objectId && e.objectClass === objectClass) if (e === undefined) { - result.push({ objectId, objectClass, parentNode: parent ?? node }) + result.push({ objectId, objectClass, parentNode: parent ?? node, grantsAccess }) + } else { + // any-wins: a person is denied only if EVERY reference is explicitly + // 'false'. Upgrade away from 'false' as soon as a non-'false' (grant + // or default) reference appears; upgrade default->'true' cosmetically. + if (e.grantsAccess === 'false' && grantsAccess !== 'false') { + e.grantsAccess = grantsAccess + } else if (e.grantsAccess === undefined && grantsAccess === 'true') { + e.grantsAccess = 'true' + } } } return true diff --git a/foundations/core/packages/text/src/nodes/reference.ts b/foundations/core/packages/text/src/nodes/reference.ts index 8a9bbf685ab..2d0d06bc512 100644 --- a/foundations/core/packages/text/src/nodes/reference.ts +++ b/foundations/core/packages/text/src/nodes/reference.ts @@ -22,6 +22,7 @@ export interface ReferenceNodeProps { id: Ref objectclass: Ref> label: string + grantsAccess?: 'true' | 'false' fixed?: boolean } @@ -44,6 +45,12 @@ export const ReferenceNode = Node.create({ id: getDataAttribute('id'), objectclass: getDataAttribute('objectclass'), label: getDataAttribute('label'), + grantsAccess: { + default: null, + parseHTML: (element) => element.getAttribute('data-grants-access'), + renderHTML: (attributes) => + attributes.grantsAccess == null ? null : { 'data-grants-access': attributes.grantsAccess } + }, // V3: keep deny flag across edit round-trips fixed: { default: null, parseHTML: (element) => element.getAttribute('data-fixed') === 'true', @@ -110,10 +117,13 @@ function getAttrs (el: HTMLSpanElement): Attrs | false { return false } + const grantsAccess = el.dataset.grantsAccess + return { id, label, objectclass, + ...(grantsAccess !== undefined ? { grantsAccess } : {}), fixed: fixed || null } } From ddb5018d065d71cb216f90932f6612c17195c5e2 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:20:15 +0000 Subject: [PATCH 19/36] feat(chunter): V3b - per-grantee grant choices in send disclosure Move the grant decision from the context-free mention popup into the send-time disclosure, which already knows the grant target, space members and new grantees. The dialog shows a checkbox per new grantee; unchecking rewrites every reference to that person in the message markup to grantsAccess='false' (all of them, so any-wins cannot re-grant) via the pure applyMentionGrantChoices helper. Existing members' mentions are left untouched. Signed-off-by: Michael Uray (cherry picked from commit 49c5117b5de572ff9744e6110fc1d7d161aa529e) Signed-off-by: Michael Uray --- .../src/__tests__/mentionGrants.test.ts | 50 +++++++++++ .../chat-message/ChatMessageInput.svelte | 88 +++++++++---------- .../chat-message/MentionGrantConfirm.svelte | 72 +++++++++++++++ .../chunter-resources/src/mentionGrants.ts | 48 ++++++++++ 4 files changed, 214 insertions(+), 44 deletions(-) create mode 100644 plugins/chunter-resources/src/__tests__/mentionGrants.test.ts create mode 100644 plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte create mode 100644 plugins/chunter-resources/src/mentionGrants.ts diff --git a/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts b/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts new file mode 100644 index 00000000000..bbf5f5eab9f --- /dev/null +++ b/plugins/chunter-resources/src/__tests__/mentionGrants.test.ts @@ -0,0 +1,50 @@ +import { markupToJSON, jsonToMarkup, type MarkupNode, MarkupNodeType } from '@hcengineering/text-core' +import { applyMentionGrantChoices } from '../mentionGrants' + +function refNode (id: string): MarkupNode { + return { type: MarkupNodeType.reference, attrs: { id, label: id, objectclass: 'contact:class:Person' } } +} +function docMarkup (...refs: MarkupNode[]): string { + return jsonToMarkup({ type: MarkupNodeType.doc, content: refs }) +} +function grantsOf (markup: string, id: string): Array<'true' | 'false' | undefined> { + const node = markupToJSON(markup) + const out: Array<'true' | 'false' | undefined> = [] + const walk = (n: MarkupNode): void => { + if (n.type === MarkupNodeType.reference && n.attrs?.id === id) { + out.push(n.attrs.grantsAccess as 'true' | 'false' | undefined) + } + n.content?.forEach(walk) + } + walk(node) + return out +} + +describe('applyMentionGrantChoices', () => { + test('deselect sets grantsAccess=false on the person reference', () => { + const result = applyMentionGrantChoices(docMarkup(refNode('p1')), new Map([['p1', false]])) + expect(grantsOf(result, 'p1')).toEqual(['false']) + }) + + test('select sets grantsAccess=true explicitly', () => { + const result = applyMentionGrantChoices(docMarkup(refNode('p1')), new Map([['p1', true]])) + expect(grantsOf(result, 'p1')).toEqual(['true']) + }) + + test('deselect rewrites EVERY reference to that person', () => { + const result = applyMentionGrantChoices(docMarkup(refNode('p1'), refNode('p1')), new Map([['p1', false]])) + expect(grantsOf(result, 'p1')).toEqual(['false', 'false']) + }) + + test('persons absent from the choice map are left unchanged', () => { + const result = applyMentionGrantChoices(docMarkup(refNode('p1'), refNode('p2')), new Map([['p1', false]])) + expect(grantsOf(result, 'p1')).toEqual(['false']) + expect(grantsOf(result, 'p2')).toEqual([undefined]) + }) + + test('empty choice map is a no-op', () => { + const markup = docMarkup(refNode('p1')) + const result = applyMentionGrantChoices(markup, new Map()) + expect(grantsOf(result, 'p1')).toEqual([undefined]) + }) +}) diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index 9abc5218196..f8150c178eb 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -29,8 +29,7 @@ type Space, type CommitResult } from '@hcengineering/core' - import { getEmbeddedLabel } from '@hcengineering/platform' - import { createQuery, DraftController, draftsStore, getClient, MessageBox } from '@hcengineering/presentation' + import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation' import { EmptyMarkup, isEmptyMarkup, markupToJSON } from '@hcengineering/text' import { extractReferences } from '@hcengineering/text-core' import { createEventDispatcher } from 'svelte' @@ -39,7 +38,9 @@ import { getSpace, editingMessageStore } from '@hcengineering/activity-resources' import { getChannelSpace } from '../../utils' + import { applyMentionGrantChoices } from '../../mentionGrants' import ChannelTypingInfo from '../ChannelTypingInfo.svelte' + import MentionGrantConfirm from './MentionGrantConfirm.svelte' export let object: Doc export let chatMessage: ChatMessage | undefined = undefined @@ -140,76 +141,73 @@ } /** - * Disclosure UX for the mention-grants-access flow. If the message - * mentions a Person whose AccountUuid is NOT already in the resolved - * grant-target's space.members, show a confirmation dialog before - * submitting. The actual access grant happens server-side via the - * chunter trigger (Collaborator record) + SpaceSecurity middleware - * (provideSecurity OR-branch). This dialog only surfaces the fact - * to the actor — a scripted API client could still bypass it. - * - * Returns true if the message should be sent, false to cancel. + * Disclosure UX for the mention-grants-access flow. Resolves the set of NEW + * grantees (mentioned Persons not already in the grant-target space) and asks + * the actor to confirm/deselect each. Returns: + * - null => cancel (do not send) + * - Map => send; map is personId -> grant choice (true/false). Empty map + * when there is nothing to disclose (send unchanged). + * The actual access grant happens server-side via the chunter trigger; this + * dialog only discloses + lets the actor opt specific people out. */ - async function confirmMentionGrantsAccess (markup: string): Promise { - if (markup === undefined || markup === '' || isEmptyMarkup(markup)) return true + async function resolveMentionGrantChoices (markup: string): Promise | null> { + if (markup === undefined || markup === '' || isEmptyMarkup(markup)) return new Map() let node try { node = markupToJSON(markup) } catch { - return true + return new Map() } const references = extractReferences(node) const mentionedPersonIds = references .filter(({ objectClass }) => hierarchy.isDerived(objectClass, contact.class.Person)) + .filter(({ grantsAccess }) => grantsAccess !== 'false') // V3c: already-denied refs need no disclosure .map(({ objectId }) => objectId as Ref) - if (mentionedPersonIds.length === 0) return true + if (mentionedPersonIds.length === 0) return new Map() const grantTarget = await resolveMentionGrantTarget(object, (cls, q) => client.findAll(cls, q)) - if (grantTarget == null) return true + if (grantTarget == null) return new Map() const space = (await client.findAll(core.class.Space, { _id: grantTarget.space }))[0] - if (space === undefined) return true + if (space === undefined) return new Map() const members = new Set(space.members ?? []) const persons = await client.findAll(contact.class.Person, { _id: { $in: mentionedPersonIds } }) - const newGrantees = persons.filter( - (p) => p.personUuid != null && !members.has(p.personUuid as AccountUuid) - ) - if (newGrantees.length === 0) return true - - const names = newGrantees.map((p) => `${p.name ?? p.personUuid}`).join(', ') - const targetName: string = - (grantTarget as any).name ?? (grantTarget as any).title ?? grantTarget._id + const newGrantees = persons.filter((p) => p.personUuid != null && !members.has(p.personUuid as AccountUuid)) + if (newGrantees.length === 0) return new Map() + + const targetName: string = (grantTarget as any).name ?? (grantTarget as any).title ?? grantTarget._id const spaceName: string = (space as any).name ?? space._id + const grantees = newGrantees.map((p) => ({ id: p._id, name: p.name ?? String(p.personUuid) })) - return await new Promise((resolve) => { + return await new Promise | null>((resolve) => { showPopup( - MessageBox, - { - label: getEmbeddedLabel('Heads up — this mention grants access'), - message: getEmbeddedLabel( - `${names} ${ - newGrantees.length === 1 ? 'is not a member' : 'are not members' - } of "${spaceName}". Sending this comment will grant ${ - newGrantees.length === 1 ? 'them' : 'them' - } read access to "${targetName}" and the ability to post comments on it. They will not be able to edit the document's fields. This is enforced by server policy; if you cancel, no access is granted.` - ), - okLabel: getEmbeddedLabel('Send and grant access'), - dangerous: false, - canSubmit: true - }, + MentionGrantConfirm, + { grantees, targetName, spaceName }, undefined, - (res?: boolean) => { - resolve(res === true) + (res?: Map) => { + resolve(res instanceof Map ? res : null) } ) }) } + // Runs the mention-grants disclosure for an outgoing/edited message and + // rewrites event.detail.message with the actor's per-grantee choices. + // Returns false if the actor cancelled (caller must abort the send). + async function prepareMentionGrantChoices (event: CustomEvent): Promise { + const markup = event.detail?.message + const choices = await resolveMentionGrantChoices(typeof markup === 'string' ? markup : '') + if (choices === null) return false // actor cancelled + if (choices.size > 0 && typeof markup === 'string') { + event.detail.message = applyMentionGrantChoices(markup, choices) + } + return true + } + async function handleCreate (event: CustomEvent, _id: Ref): Promise { try { - const proceed = await confirmMentionGrantsAccess(event.detail?.message ?? '') - if (!proceed) return + if (!(await prepareMentionGrantChoices(event))) return const res = await createMessage(event, _id, `chunter.create.${_class} ${object._class}`) @@ -225,6 +223,8 @@ async function handleEdit (event: CustomEvent): Promise { try { + if (!(await prepareMentionGrantChoices(event))) return + await editMessage(event) const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageEdited, { ok: true, objectId, objectClass: object._class }) diff --git a/plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte b/plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte new file mode 100644 index 00000000000..bed99407fee --- /dev/null +++ b/plugins/chunter-resources/src/components/chat-message/MentionGrantConfirm.svelte @@ -0,0 +1,72 @@ + + + +
+
+
+
+
+ {#each rows as row (row.id)} +
+ + {row.name} +
+ {/each} +
+
+
+ + + diff --git a/plugins/chunter-resources/src/mentionGrants.ts b/plugins/chunter-resources/src/mentionGrants.ts new file mode 100644 index 00000000000..802f2196d0c --- /dev/null +++ b/plugins/chunter-resources/src/mentionGrants.ts @@ -0,0 +1,48 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// + +import { markupToJSON, jsonToMarkup, type MarkupNode, MarkupNodeType } from '@hcengineering/text-core' +import { type Markup } from '@hcengineering/core' + +/** + * Rewrite the grantsAccess attribute on mention references in a message's + * markup according to per-person choices made in the send-time disclosure. + * + * - choice === false -> set grantsAccess='false' on EVERY reference to that + * person. Setting all of them is required because extractReferences merges + * any-wins: a single remaining undefined reference would re-grant access. + * - choice === true -> set grantsAccess='true' explicitly. + * - person not in the map -> left unchanged (e.g. existing space members, + * whose mentions stay undefined and grant-by-default as before). + * + * Pure function: returns a new markup string; does not mutate its input + * string (it parses, mutates the parsed tree, and re-serializes). + */ +export function applyMentionGrantChoices (markup: Markup, choices: Map): Markup { + if (choices.size === 0) return markup + let node: MarkupNode + try { + node = markupToJSON(markup) + } catch { + return markup + } + + const walk = (n: MarkupNode): void => { + if (n.type === MarkupNodeType.reference && n.attrs !== undefined) { + const id = n.attrs.id as string + const choice = choices.get(id) + if (choice !== undefined) { + n.attrs.grantsAccess = choice ? 'true' : 'false' + } + } + n.content?.forEach(walk) + } + walk(node) + + return jsonToMarkup(node) +} From b72c062392accc228164321557c010af8f9c4e87 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:31:36 +0000 Subject: [PATCH 20/36] feat(chunter-trigger): V3c - honour grantsAccess='false' on mentions The mention-grants trigger now skips Collaborator creation for any reference whose grantsAccess is explicitly 'false' (set by the V3b send-time disclosure). undefined still grants (pre-V3 default). This is the authoritative server-side enforcement; the client disclosure filter is UX only. Covered by a ChunterTrigger unit test that asserts a denied mention produces no Collaborator tx. Signed-off-by: Michael Uray (cherry picked from commit 1c8cd3efd771e89df017e8d6b740117a9acf2b46) Signed-off-by: Michael Uray --- .../__tests__/mentionGrantsTrigger.test.ts | 153 ++++++++++++++++++ server-plugins/chunter-resources/src/index.ts | 1 + 2 files changed, 154 insertions(+) create mode 100644 server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts diff --git a/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts new file mode 100644 index 00000000000..0bc712fe194 --- /dev/null +++ b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts @@ -0,0 +1,153 @@ +import { type MeasureContext, type Tx, type TxCreateDoc } from '@hcengineering/core' +import { jsonToMarkup, MarkupNodeType } from '@hcengineering/text-core' + +// ------------------------------------------------------------------ +// jest.mock for @hcengineering/core +// +// jest.requireActual('@hcengineering/core') crashes in ts-jest via spread +// due to a circular-dependency in the compiled lib (import_component2 +// undefined during getter evaluation when all properties are accessed +// eagerly). We work around this by requiring the actual module and only +// accessing specific known-safe properties instead of spreading. +// ------------------------------------------------------------------ +jest.mock('@hcengineering/core', () => { + // Load the real module but access only the specific properties we need. + // DO NOT spread with {...actual}: that triggers all lazy getters eagerly, + // hitting the circular-dep crash in import_component2. + const actual = jest.requireActual('@hcengineering/core') as any + + // Build a proxy that delegates unknown property reads to the actual module. + // This avoids the eager spread while still giving server-core etc. access + // to toFindResult, TxProcessor, etc. + const proxy = new Proxy(actual, { + get (target: any, prop: string) { + if (prop === 'getClassCollaborators') return mockGetClassCollaborators + if (prop === 'resolveMentionGrantTarget') return mockResolveMentionGrantTarget + return target[prop] + } + }) + + const mockGetClassCollaborators = jest.fn(() => ({ provideSecurity: true, mentionsGrantAccess: true })) + const mockResolveMentionGrantTarget = jest.fn(async (doc: any) => doc) + + return proxy +}) + +jest.mock('@hcengineering/server-contact', () => ({ + getAccountBySocialId: jest.fn(async () => 'author-acc'), + getPerson: jest.fn(async () => undefined) +})) + +// Imported AFTER the mocks so the trigger picks up the mocked helpers. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { ChunterTrigger } = require('../index') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const coreModule = require('@hcengineering/core') +const coreDefault = coreModule.default + +function makeMarkup (...refs: Array<{ id: string, grantsAccess?: 'true' | 'false' }>): string { + return jsonToMarkup({ + type: MarkupNodeType.doc, + content: refs.map((r) => ({ + type: MarkupNodeType.reference, + attrs: { + id: r.id, + label: r.id, + objectclass: 'contact:class:Person', + ...(r.grantsAccess !== undefined ? { grantsAccess: r.grantsAccess } : {}) + } + })) + }) +} + +const TARGET = { _id: 'issue-1', _class: 'tracker:class:Issue', space: 'space-1' } + +function makeControl (): any { + return { + hierarchy: { + // ChunterTrigger dispatches on isDerived(tx.objectClass, ChatMessage) + // FIRST (index.ts:360) — without the ChatMessage branch OnChatMessageCreated + // never runs. Mentioned refs are Persons; ThreadMessage/Channel stay false. + isDerived: (cls: string, base: string) => + (base === 'chunter:class:ChatMessage' && cls === 'chunter:class:ChatMessage') || + (base === 'contact:class:Person' && cls === 'contact:class:Person') + }, + modelDb: {}, + txFactory: { + createTxCreateDoc: (_class: string, space: string, attributes: any) => ({ + _class: coreDefault.class.TxCreateDoc, + objectClass: _class, + objectSpace: space, + attributes + }) + }, + findAll: jest.fn(async (_ctx: any, _class: string, query: any) => { + if (_class === coreDefault.class.Collaborator) { + // NON-EMPTY -> skip the legacy init branch (index.ts:204) and provide + // the grant-target dedup basis (existing-acc already a collaborator). + return [{ collaborator: 'existing-acc' }] + } + if (_class === coreDefault.class.ClassCollaborators) { + return [{ provideSecurity: true }] + } + if (typeof _class === 'string' && _class.includes('Employee')) { + // Query-based: return an Employee for EVERY requested id. If the mock + // hardcoded only p1, the test would pass even WITHOUT the grantsAccess + // filter (p2 never returned) — a false positive. Returning all requested + // ids means a missing filter WOULD grant p2, so the test is red before + // Step 3 and green after. + const ids: string[] = query?._id?.$in ?? [] + return ids.map((id: string) => ({ _id: id, personUuid: `${id}-acc` })) + } + // targetDoc lookup (message.attachedTo) + return [TARGET] + }), + ctx: { + with: async (_name: string, _params: any, fn: any) => await fn({}), + contextData: {} + } + } +} + +function makeCreateTx (markup: string): any { + return { + _class: coreDefault.class.TxCreateDoc, + objectClass: 'chunter:class:ChatMessage', + objectId: 'msg-1', + objectSpace: 'space-1', + modifiedBy: 'social-author', + attributes: { + attachedTo: TARGET._id, + attachedToClass: TARGET._class, + message: markup, + collection: 'comments' + } + } +} + +describe('ChunterTrigger mention grants — grantsAccess filter', () => { + test("a reference with grantsAccess='false' yields no Collaborator tx for that person", async () => { + const ctx = {} as unknown as MeasureContext + const control = makeControl() + const tx = makeCreateTx(makeMarkup({ id: 'p1' }, { id: 'p2', grantsAccess: 'false' })) + + const res: Tx[] = await ChunterTrigger([tx], control) + + const granted = res + .filter( + (t): t is TxCreateDoc => + t._class === coreDefault.class.TxCreateDoc && (t as any).objectClass === coreDefault.class.Collaborator + ) + .map((t) => (t as any).attributes.collaborator) + + expect(granted).toContain('p1-acc') // p1 granted (no deny flag) + expect(granted).not.toContain('p2-acc') // p2 denied via grantsAccess='false' + + // Stronger: the denied person must be filtered BEFORE the Employee query, + // so the query's id list must be exactly ['p1'] (not ['p1','p2']). + const employeeCall = control.findAll.mock.calls.find( + (c: any[]) => typeof c[1] === 'string' && c[1].includes('Employee') + ) + expect(employeeCall?.[2]?._id?.$in).toEqual(['p1']) + }) +}) diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index 582f7fc5baf..d95af2deabc 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -189,6 +189,7 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro const references = extractReferences(node) const mentionedPersons = references .filter(({ objectClass }) => control.hierarchy.isDerived(objectClass, contact.class.Person)) + .filter(({ grantsAccess }) => grantsAccess !== 'false') // V3c: skip explicitly-denied mentions .map(({ objectId }) => objectId as Ref) const employees = mentionedPersons.length > 0 From 1a8f32444f637fedd525aedc1dbb500d585dc540 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:35:36 +0000 Subject: [PATCH 21/36] feat(chunter-trigger): V3d - add-only re-grant on message edit Add an OnChatMessageUpdated branch that re-runs the mention-grant logic on an edited message's new text. It only ADDS Collaborator records (existing ones dedup to no-ops) and never removes: TxUpdateDoc carries no pre-update state to diff against, and Collaborator has no provenance to safely remove mention-sourced grants by. Revoke-on-removal is deferred to a future provenance model (shared with the rendered-mention revoke stream). Signed-off-by: Michael Uray (cherry picked from commit 650ed5e78732202897b750ddb00de9e18a52fb4d) Signed-off-by: Michael Uray --- server-plugins/chunter-resources/src/index.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index d95af2deabc..7ca386c3356 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -277,6 +277,74 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD, contro return res } +// V3d: self-contained grant helper used ONLY by OnChatMessageUpdated. +// Do NOT refactor OnChatMessageCreated to call this — the create path is live-tested +// and must remain provably unchanged. The ~25-line overlap is intentional. +async function applyMentionGrants (ctx: MeasureContext, message: ChatMessage, control: TriggerControl): Promise { + if (message.modifiedBy === core.account.System) return [] + const mixin = getClassCollaborators(control.modelDb, control.hierarchy, message.attachedToClass) + if (mixin === undefined) return [] + + const targetDoc = (await control.findAll(ctx, message.attachedToClass, { _id: message.attachedTo }, { limit: 1 }))[0] + if (targetDoc === undefined) return [] + + const node = markupToJSON(message.message) + const references = extractReferences(node) + const mentionedPersons = references + .filter(({ objectClass }) => control.hierarchy.isDerived(objectClass, contact.class.Person)) + .filter(({ grantsAccess }) => grantsAccess !== 'false') // V3c + .map(({ objectId }) => objectId as Ref) + // V3d is "a newly-added mention grants access". With no granting mention there + // is nothing to do — return early so a plain text edit never re-runs grant + // machinery, and the author is NOT re-added as a collaborator on every edit. + if (mentionedPersons.length === 0) return [] + const employees = await control.findAll(ctx, contact.mixin.Employee, { + _id: { $in: mentionedPersons as Ref[] } + }) + // Update path grants ONLY the mentioned employees (not the author — the + // author was already added at create time; the create path is unchanged). + const collaboratorsFromMessage = employees.map((it) => it.personUuid).filter(notEmpty) + if (collaboratorsFromMessage.length === 0) return [] + + const grantTarget = await resolveMentionGrantTarget(targetDoc, (cls, q) => control.findAll(control.ctx, cls, q)) + if (grantTarget == null) return [] // update-grant only applies to opted-in (protected) targets + + const grantCollabs = ( + await control.findAll(control.ctx, core.class.Collaborator, { attachedTo: grantTarget._id }) + ).map((c) => c.collaborator) + + const res: Tx[] = [] + for (const collab of collaboratorsFromMessage) { + if (grantCollabs.includes(collab)) continue // add-only: skip existing + res.push( + control.txFactory.createTxCreateDoc(core.class.Collaborator, grantTarget.space, { + attachedTo: grantTarget._id, + attachedToClass: grantTarget._class, + collaborator: collab, + collection: 'collaborators' + }) + ) + } + return res +} + +async function OnChatMessageUpdated (ctx: MeasureContext, tx: TxCUD, control: TriggerControl): Promise { + const actualTx = tx as TxUpdateDoc + // Only act when the message body changed (a new mention may have been added). + if (actualTx.operations.message === undefined) return [] + + const current = (await control.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] as + | ChatMessage + | undefined + if (current === undefined) return [] + + // Use the new text from the update operations (add-only: we grant for all + // currently-mentioned people; existing grants are deduped to no-ops, and we + // never remove — Collaborator has no provenance to safely remove by). + const message: ChatMessage = { ...current, message: actualTx.operations.message } + return await applyMentionGrants(ctx, message, control) +} + async function ChatNotificationsHandler (txes: TxCUD[], control: TriggerControl): Promise { const result: Tx[] = [] for (const tx of txes) { @@ -364,6 +432,14 @@ export async function ChunterTrigger (txes: TxCUD[], control: TriggerContro ) { res.push(...(await control.ctx.with('OnChatMessageCreated', {}, (ctx) => OnChatMessageCreated(ctx, tx, control)))) } + if ( + tx._class === core.class.TxUpdateDoc && + control.hierarchy.isDerived(tx.objectClass, chunter.class.ChatMessage) + ) { + res.push( + ...(await control.ctx.with('OnChatMessageUpdated', {}, (ctx) => OnChatMessageUpdated(ctx, tx, control))) + ) + } } return res } From 765da62591a2b7dbd0440649256a89bde8d3dc9f Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:52:33 +0000 Subject: [PATCH 22/36] fix(chunter-trigger): V3d update-grant uses the edit actor + add tests OnChatMessageUpdated rebuilt the message via { ...current, message } which kept the original author's modifiedBy, so applyMentionGrants' System guard checked the wrong actor. Use TxProcessor.updateDoc2Doc so modifiedBy reflects the edit tx. Add ChunterTrigger unit tests for the update path: new-mention grant, denied-mention (V3c) on edit, existing-collaborator dedup, System-authored edit grants nothing, and non-message update no-op. Signed-off-by: Michael Uray (cherry picked from commit 7cb590ff1c6b0bd4ff6dac17e5e3f5d7c38a86b9) Signed-off-by: Michael Uray --- .../__tests__/mentionGrantsTrigger.test.ts | 92 +++++++++++++++++++ server-plugins/chunter-resources/src/index.ts | 10 +- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts index 0bc712fe194..6b2d2f36717 100644 --- a/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts +++ b/server-plugins/chunter-resources/src/__tests__/mentionGrantsTrigger.test.ts @@ -99,6 +99,24 @@ function makeControl (): any { const ids: string[] = query?._id?.$in ?? [] return ids.map((id: string) => ({ _id: id, personUuid: `${id}-acc` })) } + if (_class === 'chunter:class:ChatMessage') { + // OnChatMessageUpdated loads the current stored message by _id before + // applying the update operations. Return a ChatMessage shape with the + // OLD (no-mention) body; the update tx supplies the new text. + return [ + { + _id: 'msg-1', + _class: 'chunter:class:ChatMessage', + space: 'space-1', + attachedTo: TARGET._id, + attachedToClass: TARGET._class, + collection: 'comments', + message: makeMarkup(), + modifiedBy: 'social-author', + modifiedOn: 1 + } + ] + } // targetDoc lookup (message.attachedTo) return [TARGET] }), @@ -125,6 +143,27 @@ function makeCreateTx (markup: string): any { } } +function makeUpdateTx (operations: any, modifiedBy = 'social-editor'): any { + return { + _class: coreDefault.class.TxUpdateDoc, + objectClass: 'chunter:class:ChatMessage', + objectId: 'msg-1', + objectSpace: 'space-1', + modifiedBy, + modifiedOn: 2, + operations + } +} + +function collaboratorGrants (res: Tx[]): string[] { + return res + .filter( + (t): t is TxCreateDoc => + t._class === coreDefault.class.TxCreateDoc && (t as any).objectClass === coreDefault.class.Collaborator + ) + .map((t) => (t as any).attributes.collaborator) +} + describe('ChunterTrigger mention grants — grantsAccess filter', () => { test("a reference with grantsAccess='false' yields no Collaborator tx for that person", async () => { const ctx = {} as unknown as MeasureContext @@ -151,3 +190,56 @@ describe('ChunterTrigger mention grants — grantsAccess filter', () => { expect(employeeCall?.[2]?._id?.$in).toEqual(['p1']) }) }) + +describe('ChunterTrigger mention grants — V3d add-only re-grant on edit', () => { + test('a newly-added mention on edit grants the mentioned employee', async () => { + const control = makeControl() + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3' }) }) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).toContain('p3-acc') + }) + + test('a denied mention on edit grants nothing (V3c filter still applies)', async () => { + const control = makeControl() + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3', grantsAccess: 'false' }) }) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).not.toContain('p3-acc') + }) + + test('an already-granted collaborator is deduped — add-only no-op', async () => { + const control = makeControl() + // 'existing' resolves to personUuid 'existing-acc', which the grant target + // already lists (findAll Collaborator returns existing-acc) -> no new tx. + const tx = makeUpdateTx({ message: makeMarkup({ id: 'existing' }) }) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).not.toContain('existing-acc') + expect(collaboratorGrants(res)).toHaveLength(0) + }) + + test('a System-authored edit grants nothing (stale modifiedBy guard — uses edit actor)', async () => { + const control = makeControl() + // updateDoc2Doc sets message.modifiedBy = tx.modifiedBy = System, so the + // applyMentionGrants System guard fires. If the handler used the stored + // doc's (non-System) author instead, this would wrongly grant. + const tx = makeUpdateTx({ message: makeMarkup({ id: 'p3' }) }, coreDefault.account.System) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).toHaveLength(0) + }) + + test('a non-message update (no operations.message) is a no-op', async () => { + const control = makeControl() + const tx = makeUpdateTx({ reactions: 1 }) + + const res: Tx[] = await ChunterTrigger([tx], control) + + expect(collaboratorGrants(res)).toHaveLength(0) + }) +}) diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index 7ca386c3356..d50b6dae768 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -338,10 +338,12 @@ async function OnChatMessageUpdated (ctx: MeasureContext, tx: TxCUD, contro | undefined if (current === undefined) return [] - // Use the new text from the update operations (add-only: we grant for all - // currently-mentioned people; existing grants are deduped to no-ops, and we - // never remove — Collaborator has no provenance to safely remove by). - const message: ChatMessage = { ...current, message: actualTx.operations.message } + // Apply the update to the stored doc so the message text AND the actor + // (modifiedBy) reflect THIS edit — not the original author. applyMentionGrants + // guards on message.modifiedBy === System, so it must see the edit actor. + // Add-only: we grant for all currently-mentioned people; existing grants dedup + // to no-ops, and we never remove (Collaborator has no provenance to remove by). + const message = TxProcessor.updateDoc2Doc({ ...current }, actualTx) return await applyMentionGrants(ctx, message, control) } From f9fe39f71c6682ae85b165c4dda49e3648c756a5 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Wed, 27 May 2026 21:52:33 +0000 Subject: [PATCH 23/36] fix(chunter): preserve unsent comment when grant dialog is cancelled V3b made the send flow async (the per-grantee disclosure popup), but onMessage removed the draft and cleared the input BEFORE the popup resolved and did not await handleCreate. Cancelling the grant dialog therefore lost the unsent comment. handleCreate/handleEdit now return a boolean; onMessage awaits both and only drops the draft + clears the input + dispatches submit on a successful send/edit. Signed-off-by: Michael Uray (cherry picked from commit 34bc13a333318c2bc04457ae1cdcc87f5b97ca4a) Signed-off-by: Michael Uray --- .../chat-message/ChatMessageInput.svelte | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte index f8150c178eb..a7238d3a82f 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageInput.svelte @@ -205,51 +205,63 @@ return true } - async function handleCreate (event: CustomEvent, _id: Ref): Promise { + // Returns true if the message was sent, false if the actor cancelled the + // grant dialog or the send failed. The caller (onMessage) must only clear + // the draft/input on a true result, otherwise a cancelled grant dialog + // would lose the unsent comment. + async function handleCreate (event: CustomEvent, _id: Ref): Promise { try { - if (!(await prepareMentionGrantChoices(event))) return + if (!(await prepareMentionGrantChoices(event))) return false const res = await createMessage(event, _id, `chunter.create.${_class} ${object._class}`) console.log(`create.${_class} measure`, res.serverTime, res.time) const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageCreated, { ok: res.result, objectId, objectClass: object._class }) + return true } catch (err: any) { const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageCreated, { ok: false, objectId, objectClass: object._class }) Analytics.handleError(err) + return false } } - async function handleEdit (event: CustomEvent): Promise { + async function handleEdit (event: CustomEvent): Promise { try { - if (!(await prepareMentionGrantChoices(event))) return + if (!(await prepareMentionGrantChoices(event))) return false await editMessage(event) const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageEdited, { ok: true, objectId, objectClass: object._class }) + return true } catch (err: any) { const objectId = await getObjectId(object, client.getHierarchy()) Analytics.handleEvent(ChunterEvents.MessageEdited, { ok: false, objectId, objectClass: object._class }) Analytics.handleError(err) + return false } } async function onMessage (event: CustomEvent): Promise { - draftController.remove() - inputRef.removeDraft(false) + loading = true + let ok = false if (chatMessage !== undefined) { - loading = true - await handleEdit(event) + ok = await handleEdit(event) } else { - void handleCreate(event, _id) + ok = await handleCreate(event, _id) void deleteTypingInfo() } - // Remove draft from Local Storage - clear() - dispatch('submit', false) + // Only clear the input / drop the draft after a successful send or edit. + // A cancelled grant dialog (ok === false) must preserve the unsent comment. + if (ok) { + draftController.remove() + inputRef.removeDraft(false) + clear() + dispatch('submit', false) + } loading = false } From 2f6657737b8b9d7cca600df433bfb34a45045cb0 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 30 May 2026 07:24:04 +0000 Subject: [PATCH 24/36] test(middleware): stub hierarchy.getAncestors for guest-permission uncovered-class test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isForbiddenCollabOnlyGuestFieldUpdate (added by 8e825a54db) walks the class hierarchy via getClassCollaborators → getAncestors. The "uncovered class" test scaffold patched classHierarchyMixin and isDerived but not getAncestors, so the new code path threw "ancestors not found: test:class:UncoveredClass" before reaching the production-style early-return. Returning [] from the stub matches the intent: an uncovered class has no ancestors to inherit ClassCollaborators from. Signed-off-by: Michael Uray --- .../packages/middleware/src/tests/guestPermissions.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts index e124aee8cbe..2d9c09cb45e 100644 --- a/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts +++ b/foundations/server/packages/middleware/src/tests/guestPermissions.test.ts @@ -192,6 +192,7 @@ describe('GuestPermissionsMiddleware', () => { return a === b } ;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined + ;(mw as any).context.hierarchy.getAncestors = () => [] } it('allows create for covered class in any space (TxAccessLevel is irrelevant)', async () => { @@ -276,6 +277,7 @@ describe('GuestPermissionsMiddleware', () => { if (b === core.class.Space) return false return a === b } + ;(mw as any).context.hierarchy.getAncestors = () => [] const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE) const ctx = makeCtx(makeAccount(AccountRole.Guest)) @@ -310,6 +312,7 @@ describe('GuestPermissionsMiddleware', () => { if (b === core.class.Space) return false return a === b } + ;(mw as any).context.hierarchy.getAncestors = () => [] const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE) const ctx = makeCtx(makeAccount(AccountRole.Guest)) @@ -337,6 +340,7 @@ describe('GuestPermissionsMiddleware', () => { if (b === core.class.Space) return false return a === b } + ;(mw as any).context.hierarchy.getAncestors = () => [] const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE) const ctx = makeCtx(makeAccount(AccountRole.Guest)) @@ -364,6 +368,7 @@ describe('GuestPermissionsMiddleware', () => { if (b === core.class.Space) return false return a === b } + ;(mw as any).context.hierarchy.getAncestors = () => [] } it('allows guest to update document created by same account', async () => { @@ -468,6 +473,7 @@ describe('GuestPermissionsMiddleware', () => { return a === b } ;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined + ;(mw as any).context.hierarchy.getAncestors = () => [] // First tx as guest should load cache const userCtx = makeCtx(makeAccount(AccountRole.User)) From d388c5a891d42033fd2f36f46a8de375a5bdc64b Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 30 May 2026 07:34:21 +0000 Subject: [PATCH 25/36] fix(workbench-resources/Navigator): correct activeClasses cast to Ref>[] 64105d1f14 introduced the activeClasses derived reactive but cast it to Ref[]. Since `typeof core.class.Space` is already Ref>, that double-wrapped the type to Ref>>[], which svelte-check correctly flagged as incompatible with the collab/member query callsites that expect Ref>[]. Drop the extra Ref<> wrapper and import Class so the cast is to the actually intended Ref>[]. Signed-off-by: Michael Uray --- plugins/workbench-resources/src/components/Navigator.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/workbench-resources/src/components/Navigator.svelte b/plugins/workbench-resources/src/components/Navigator.svelte index 8be6f5d98c7..34925c73a6e 100644 --- a/plugins/workbench-resources/src/components/Navigator.svelte +++ b/plugins/workbench-resources/src/components/Navigator.svelte @@ -13,7 +13,7 @@ // limitations under the License. -->