Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
eb35ef9
feat(core): add ClassCollaborators.mentionsGrantAccess opt-in flag
MichaelUray May 25, 2026
1d4017e
feat(model/core): wire mentionsGrantAccess into TClassCollaborators m…
MichaelUray May 25, 2026
0b9fa6a
feat(tracker): opt tracker.class.Issue into collaborator-grants-read …
MichaelUray May 25, 2026
2d28f4b
feat(core/collaborators): add isomorphic resolveMentionGrantTarget he…
MichaelUray May 25, 2026
b67689c
feat(middleware/guest-permissions): veto field updates from collab-on…
MichaelUray May 25, 2026
be38db3
feat(tracker/utils): split canEditIssue into canEditIssueFields + can…
MichaelUray Jul 9, 2026
f07d6e0
feat(tracker/EditIssue): show comment composer separately from field …
MichaelUray May 25, 2026
863faf7
fix(chunter-resources): add @hcengineering/text-core dependency
MichaelUray May 25, 2026
961e33c
feat(chunter-trigger): mention-grants-access uses shared helper + gra…
MichaelUray May 25, 2026
d886ee0
feat(chunter/ChatMessageInput): warn before mention auto-grants Issue…
MichaelUray May 25, 2026
f5157a0
feat(middleware/spaceSecurity): let Guests read collab-secured docs a…
MichaelUray May 25, 2026
e6f6fcf
feat(security): allow non-System callers to read their own Collaborat…
MichaelUray May 25, 2026
bc25a70
feat(security+nav): list collab-only spaces in workbench navigator fo…
MichaelUray May 25, 2026
30ad68d
fix(nav): surface collab-only projects in tracker nav-tree
MichaelUray May 26, 2026
2c916a2
fix(tracker/nav): hide non-Issues sub-nodes on non-member projects
MichaelUray May 26, 2026
28a25d1
feat(tracker/nav): V1 - badge + tooltip for collab-only projects
MichaelUray May 27, 2026
8069e28
test(tracker): V2 - mention-grant persists after issue moves to Done
MichaelUray May 27, 2026
dc3e380
feat(text-core): V3a - grantsAccess attr on mention references
MichaelUray May 27, 2026
ddb5018
feat(chunter): V3b - per-grantee grant choices in send disclosure
MichaelUray May 27, 2026
b72c062
feat(chunter-trigger): V3c - honour grantsAccess='false' on mentions
MichaelUray May 27, 2026
1a8f324
feat(chunter-trigger): V3d - add-only re-grant on message edit
MichaelUray May 27, 2026
765da62
fix(chunter-trigger): V3d update-grant uses the edit actor + add tests
MichaelUray May 27, 2026
f9fe39f
fix(chunter): preserve unsent comment when grant dialog is cancelled
MichaelUray May 27, 2026
2f66577
test(middleware): stub hierarchy.getAncestors for guest-permission un…
MichaelUray May 30, 2026
d388c5a
fix(workbench-resources/Navigator): correct activeClasses cast to Ref…
MichaelUray May 30, 2026
4a73a17
fix: self-review BLOCKERs + IMPORTANT findings
MichaelUray May 28, 2026
34c8338
fix(tracker/V1): guard space.members against null
MichaelUray May 28, 2026
caf9ebc
docs(tracker/V1): note scope limit on sub-node hide
MichaelUray May 30, 2026
a8765c9
chore: apply repo formatting and lint fixes after develop merge
MichaelUray Jul 2, 2026
a1bb75b
fix(middleware): backend-guard collab-read space-filter bypass (H5)
MichaelUray Jul 8, 2026
97d5447
fix(middleware): collab-only Write-Veto deckt auch TxRemoveDoc (L-RM)
MichaelUray Jul 8, 2026
ec05335
fix(middleware): guestPermissions fail-closed bei fehlendem Space (L-GP)
MichaelUray Jul 8, 2026
93c13b4
fix(chunter-resources): align text-core to workspace:^0.7.19
MichaelUray Jul 9, 2026
017942d
fix(core): pass limit:1 in resolveMentionGrantTarget lookups
MichaelUray Jul 11, 2026
f8aae31
fix(server-chunter): dedup mention grants against in-flight collabora…
MichaelUray Jul 11, 2026
fc9c20b
fix(tracker-assets): add ru translation for SharedWithYouTooltip
MichaelUray Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 107 additions & 2 deletions foundations/core/packages/core/src/__tests__/collaborators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
// limitations under the License.
//

import { getClassCollaborators } from '../collaborators'
import { getClassCollaborators, resolveMentionGrantTarget } from '../collaborators'
import { ModelDb } from '../memdb'
import { Hierarchy } from '../hierarchy'
import core from '../component'
import type { Class, ClassCollaborators, Doc, Ref } from '../classes'
import type { AttachedDoc, Class, ClassCollaborators, Doc, Ref } from '../classes'

describe('collaborators', () => {
let model: ModelDb
Expand Down Expand Up @@ -198,4 +198,109 @@ describe('collaborators', () => {
expect(result).toBe(collab2)
})
})

describe('resolveMentionGrantTarget', () => {
const ISSUE = 'tracker:class:Issue' as Ref<Class<Doc>>
const THREAD = 'chunter:class:ThreadMessage' as Ref<Class<Doc>>
const MSG = 'chunter:class:ChatMessage' as Ref<Class<Doc>>

// Builds a findAll injector like the server/client pass in. `opted` is the
// set of _class refs whose ClassCollaborators is provideSecurity+mentionsGrantAccess.
// `docs` maps _id -> Doc for the attachedTo parent lookups.
function makeFindAll (
opted: Set<string>,
docs: Record<string, Doc>
): jest.Mock<Promise<any[]>, [Ref<Class<Doc>>, any, any?]> {
return jest.fn(async (cls: Ref<Class<Doc>>, q: any, _options?: any) => {
if (cls === core.class.ClassCollaborators) {
const target = q.attachedTo as string
return opted.has(target) ? [{ provideSecurity: true, mentionsGrantAccess: true }] : []
}
const doc = docs[q._id as string]
return doc != null ? [doc] : []
})
}

it('returns the start doc when it is itself opted in', async () => {
const start: Doc = { _id: 'i1' as Ref<Doc>, _class: ISSUE } as unknown as Doc
const findAll = makeFindAll(new Set([ISSUE]), {})

const result = await resolveMentionGrantTarget(start, findAll)

expect(result).toBe(start)
})

it('walks the attachedTo chain to the first opted-in ancestor', async () => {
const issue: Doc = { _id: 'i1' as Ref<Doc>, _class: ISSUE } as unknown as Doc
const thread = {
_id: 'tm1' as Ref<Doc>,
_class: THREAD,
attachedTo: 'i1' as Ref<Doc>,
attachedToClass: ISSUE
} as unknown as AttachedDoc

// ThreadMessage + ChatMessage are NOT opted in; only the Issue ancestor is.
const findAll = makeFindAll(new Set([ISSUE]), { i1: issue })

const result = await resolveMentionGrantTarget(thread, findAll)

expect(result).toBe(issue)
})

it('returns null when nothing in the chain is opted in', async () => {
const parent = { _id: 'p1' as Ref<Doc>, _class: MSG } as unknown as Doc
const child = {
_id: 'c1' as Ref<Doc>,
_class: THREAD,
attachedTo: 'p1' as Ref<Doc>,
attachedToClass: MSG
} as unknown as AttachedDoc

const findAll = makeFindAll(new Set(), { p1: parent })

const result = await resolveMentionGrantTarget(child, findAll)

expect(result).toBeNull()
})

it('returns null on an attachedTo cycle (depth cap 8)', async () => {
// Self-referential doc: attachedTo points back at itself, never opted in.
const node = {
_id: 'x' as Ref<Doc>,
_class: THREAD,
attachedTo: 'x' as Ref<Doc>,
attachedToClass: THREAD
} as unknown as AttachedDoc
const findAll = makeFindAll(new Set(), { x: node as unknown as Doc })

const result = await resolveMentionGrantTarget(node, findAll)

expect(result).toBeNull()
// Depth cap 8: exactly 8 ClassCollaborators probes (one per loop turn).
const ccProbes = findAll.mock.calls.filter((c) => c[0] === core.class.ClassCollaborators)
expect(ccProbes.length).toBe(8)
})

it('passes { limit: 1 } as the third arg on every findAll lookup (fix guard)', async () => {
const issue: Doc = { _id: 'i1' as Ref<Doc>, _class: ISSUE } as unknown as Doc
const thread = {
_id: 'tm1' as Ref<Doc>,
_class: THREAD,
attachedTo: 'i1' as Ref<Doc>,
attachedToClass: ISSUE
} as unknown as AttachedDoc
const findAll = makeFindAll(new Set([ISSUE]), { i1: issue })

await resolveMentionGrantTarget(thread, findAll)

// Both the ClassCollaborators probe and the parent lookup must be bounded.
expect(findAll).toHaveBeenCalled()
for (const call of findAll.mock.calls) {
expect(call[2]).toEqual({ limit: 1 })
}
// Sanity: at least one ClassCollaborators probe and one parent lookup ran.
expect(findAll.mock.calls.some((c) => c[0] === core.class.ClassCollaborators)).toBe(true)
expect(findAll.mock.calls.some((c) => c[0] === ISSUE)).toBe(true)
})
})
})
9 changes: 8 additions & 1 deletion foundations/core/packages/core/src/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,8 +1010,15 @@ export interface ClassCollaborators<T extends Doc> extends Doc {
attachedTo: Ref<Class<T>>
allFields?: boolean // for all (PersonId | Ref<Employee> | PersonId[] | Ref<Employee>[]) attributes
fields: (keyof T)[] // PersonId | Ref<Employee> | PersonId[] | Ref<Employee>[]
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 {
Expand Down
60 changes: 59 additions & 1 deletion foundations/core/packages/core/src/collaborators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,17 @@
// limitations under the License.
//

import core, { Class, ClassCollaborators, Doc, Hierarchy, ModelDb, Ref } from '.'
import core, {
AttachedDoc,
Class,
ClassCollaborators,
Doc,
DocumentQuery,
FindOptions,
Hierarchy,
ModelDb,
Ref
} from '.'

export function getClassCollaborators<T extends Doc> (
model: ModelDb,
Expand All @@ -35,3 +45,51 @@ export function getClassCollaborators<T extends Doc> (
}
}
}

/**
* 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, o) => control.findAll(control.ctx, cls, q, o)`, client passes
* `(cls, q, o) => getClient().findAll(cls, q, o)`.
*
* 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: <T extends Doc>(cls: Ref<Class<T>>, q: DocumentQuery<T>, options?: FindOptions<T>) => Promise<T[]>
): Promise<Doc | null> {
let cur: Doc | undefined = start
for (let i = 0; i < 8 && cur != null; i++) {
const ccQuery: DocumentQuery<ClassCollaborators<Doc>> = {
attachedTo: cur._class
}
const cc = (await findAll(core.class.ClassCollaborators, ccQuery, { limit: 1 }))[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 parentQuery: DocumentQuery<Doc> = {
_id: attached.attachedTo
}
const parent = (await findAll(attached.attachedToClass, parentQuery, { limit: 1 }))[0]
cur = parent
}
return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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')
})
})
8 changes: 7 additions & 1 deletion foundations/core/packages/text-core/src/markup/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
15 changes: 14 additions & 1 deletion foundations/core/packages/text-core/src/markup/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export interface Reference {
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
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'
}

/**
Expand All @@ -37,9 +40,19 @@ export function extractReferences (content: MarkupNode): Array<Reference> {
const reference = node as ReferenceMarkupNode
const objectId = reference.attrs.id as Ref<Doc>
const objectClass = reference.attrs.objectclass as Ref<Class<Doc>>
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
Expand Down
Loading