const pull = require("../server/node_modules/pull-stream") const crypto = require("crypto") const { getConfig } = require("../configs/config-manager.js") const { buildValidatedTombstoneSet } = require('./tombstone_validator') const { readTyped, memoIndex } = require('./typed_log') const { collabContent, openInviteOf } = require('../backend/collab_content') const calCollab = collabContent({ membersField: 'participants', undecField: 'encrypted', contentFields: ['title', 'deadline', 'status'], listFields: ['tags', 'invites'] }) const logLimit = getConfig().ssbLogStream?.limit || 1000 const INVITE_CODE_BYTES = 16 const safeText = (v) => String(v || "").trim() const normalizeTags = (raw) => { if (!raw) return [] if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean) return String(raw).split(",").map(t => t.trim()).filter(Boolean) } const hasAnyInterval = (w, m, y) => !!(w || m || y) const { expandRecurrence, truthy } = require('./recurrence') const MAX_OCCURRENCES_PER_DATE = 520 const MAX_DATES_PER_VIEW = 5000 const ts = (v) => { const t = new Date(v).getTime() return Number.isFinite(t) ? t : null } const boundedOccurrences = (date, until, weekly, monthly, yearly) => { const start = ts(date) const end = ts(until) if (start === null || end === null) return expandRecurrence(date, until, weekly, monthly, yearly).slice(0, MAX_OCCURRENCES_PER_DATE) const limit = new Date(start) if (truthy(weekly)) limit.setUTCDate(limit.getUTCDate() + 7 * MAX_OCCURRENCES_PER_DATE) else if (truthy(monthly)) limit.setUTCMonth(limit.getUTCMonth() + MAX_OCCURRENCES_PER_DATE) else limit.setUTCFullYear(limit.getUTCFullYear() + MAX_OCCURRENCES_PER_DATE) const stop = Number.isFinite(limit.getTime()) ? Math.min(end, limit.getTime()) : end return expandRecurrence(date, new Date(stop).toISOString(), weekly, monthly, yearly).slice(0, MAX_OCCURRENCES_PER_DATE) } const assertCalendarDates = ({ deadline, date, until }) => { const now = Date.now() const dl = deadline ? ts(deadline) : null const dt = date ? ts(date) : null const un = until ? ts(until) : null if (deadline && dl === null) throw new Error("Deadline is not a valid date") if (date && dt === null) throw new Error("Date is not a valid date") if (until && un === null) throw new Error("The recurrence end is not a valid date") if (dl !== null && dl <= now) throw new Error("Deadline must be in the future") if (dt !== null && dt <= now) throw new Error("Date must be in the future") if (dt !== null && dl !== null && dt > dl) throw new Error("Date cannot be later than the deadline") if (un !== null && dt !== null && un < dt) throw new Error("The recurrence cannot end before it starts") if (un !== null && dl !== null && un > dl) throw new Error("The recurrence cannot end after the deadline") } module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => { let ssb const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } const ownCrypto = calendarCrypto || tribeCrypto const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null const lookupKeys = (rid) => { const a = (ownCrypto && ownCrypto.getKeys(rid)) || [] if (a.length) return a return (tribeCrypto && tribeCrypto.getKeys(rid)) || [] } const lookupGen = (rid) => ((ownCrypto && ownCrypto.getGen(rid)) || (tribeCrypto && tribeCrypto.getGen(rid)) || 0) const keyProofFor = (keyHex, id) => { try { return crypto.createHmac("sha256", Buffer.from(keyHex, "hex")).update(String(id), "utf8").digest("hex") } catch (_) { return null } } const rotateCalendarKey = async (rootId, remainingMembers) => { if (!ownCrypto || !tribeCrypto || !rootId) return const existing = lookupKey(rootId) if (!existing) return const newKey = ownCrypto.generateTribeKey() const newGen = ownCrypto.addNewKey(rootId, newKey) const ssbClient = await openSsb() const ssbKeys = require("../server/node_modules/ssb-keys") const memberKeys = {} for (const m of new Set([ssbClient.id, ...(Array.isArray(remainingMembers) ? remainingMembers : [])])) { try { memberKeys[m] = tribeCrypto.boxKeyForMember(newKey, m, ssbKeys) } catch (_) {} } if (Object.keys(memberKeys).length) { await new Promise((resolve) => { ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: newGen, memberKeys }, () => resolve()) }) } } const ingestOwnTribeKeys = async () => { if (!ownCrypto) return try { const ssbClient = await openSsb() const ssbKeys = require("../server/node_modules/ssb-keys") const config = require("../server/ssb_config") const msgs = await readAll(ssbClient) const authorOf = new Map() for (const m of msgs) if (m && m.key && m.value) authorOf.set(m.key, m.value.author) for (const m of msgs) { const c = m.value && m.value.content if (!c || c.type !== "tribe-keys") continue if (!c.tribeId || !authorOf.has(c.tribeId) || (m.value.author !== authorOf.get(c.tribeId) && m.value.author !== ssbClient.id)) continue const memberKeys = c.memberKeys if (!memberKeys || typeof memberKeys !== "object") continue const boxed = memberKeys[ssbClient.id] if (!boxed) continue try { const unboxed = ssbKeys.unbox(boxed, config.keys) const key = typeof unboxed === "string" ? unboxed : (unboxed && unboxed.toString ? unboxed.toString() : null) if (key && c.tribeId) ownCrypto.addNewKey(c.tribeId, key) } catch (_) {} } } catch (_) {} } const CALENDAR_TYPES = ["calendar", "calendarDate", "calendarNote", "calendarParticipant", "calendarReminderSent", "tribe-keys", "tombstone"] const readAll = async (ssbClient) => unwrapForIndex(await readTyped(ssbClient, CALENDAR_TYPES, { limit: logLimit, withWindow: true })) const ensureMemberKeys = async (ssbClient, messages, cals) => { if (!tribeCrypto || !ownCrypto) return const leavesByRoot = new Map() for (const m of messages) { const c = m.value && m.value.content if (!c || c.type !== "calendarParticipant" || c.on !== false || !c.target) continue if (!leavesByRoot.has(c.target)) leavesByRoot.set(c.target, []) leavesByRoot.get(c.target).push({ member: m.value.author, ts: Number(m.timestamp || m.value.timestamp || 0) }) } const ssbKeys = require("../server/node_modules/ssb-keys") for (const cal of (Array.isArray(cals) ? cals : [])) { if (!cal || cal.tribeId || cal.encrypted || cal.author !== ssbClient.id) continue const rootId = cal.rootId if (!rootId || !lookupKey(rootId)) continue const members = Array.isArray(cal.participants) ? cal.participants : [] const plan = ownCrypto.keyPlan({ rootId, ownerId: cal.author, gen: lookupGen(rootId), members, messages, leaves: leavesByRoot.get(rootId) || [] }) if (plan.rotate) { await rotateCalendarKey(rootId, members) continue } if (!plan.missing.length) continue const key = lookupKey(rootId) const memberKeys = {} for (const m of plan.missing) { try { memberKeys[m] = tribeCrypto.boxKeyForMember(key, m, ssbKeys) } catch (_) {} } if (!Object.keys(memberKeys).length) continue await new Promise((resolve) => { ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: lookupGen(rootId) || 1, memberKeys }, () => resolve()) }) } } const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null const unwrapForIndex = (msgs) => tribeHelpers ? tribeHelpers.unwrapMessagesForKind(msgs, CALENDAR_TYPES) : msgs const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {} const encryptStandalone = (content, rootId) => { if (!tribeCrypto || !rootId) return content const key = lookupKey(rootId) if (!key) return content return tribeCrypto.encryptContent(content, [key], true) } const decryptScoped = async (content, rootId) => { if (!content) return content if (content.tribeId || (tribeCrypto && tribeCrypto.isTribeMsg(content))) return await decryptIfTribe(content) return decryptCalendarRoot(content, rootId) } const decryptCalendarRoot = (content, rootId) => { if (!content || !content.encryptedPayload) return content if (!tribeCrypto) return content const keys = lookupKeys(rootId) if (!keys || !keys.length) return { ...content, _undecryptable: true } return tribeCrypto.decryptContent(content, keys.map(k => [k])) } const tryDecryptPublicInviteKey = (invites) => { if (!tribeCrypto || !Array.isArray(invites)) return null for (const inv of invites) { if (!inv || typeof inv !== "object" || inv.public !== true) continue if (typeof inv.code !== "string") continue if (typeof inv.ek === "string") { try { const k = tribeCrypto.decryptFromInvite(inv.ek, inv.code, inv.salt); if (k) return k } catch (_) {} } if (typeof inv.ekChain === "string") { try { const chain = tribeCrypto.decryptChainFromInvite(inv.ekChain, inv.code, inv.salt); if (Array.isArray(chain) && chain.length && chain[0].key) return chain[0].key } catch (_) {} } } return null } const decryptIndexNodes = async (idx) => { if (!tribeCrypto) return for (const [k, n] of idx.nodes.entries()) { if (!n.c || !n.c.encryptedPayload) continue let root = k while (idx.parent.has(root)) root = idx.parent.get(root) let dec = null if (n.c.tribeId && tribesModel) { try { const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel) if (r && !r._undecryptable) dec = r } catch (_) {} } if (!dec) { const r = decryptCalendarRoot(n.c, root) if (r && !r._undecryptable) dec = r } if (!dec) { const pubKey = tryDecryptPublicInviteKey(n.c.invites) if (pubKey) { try { const r = tribeCrypto.decryptContent(n.c, [[pubKey]]); if (r && !r._undecryptable) dec = r } catch (_) {} } } if (dec) { idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } }) } else { idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } }) } } } const buildIndex = (messages) => { const tomb = new Set() const nodes = new Map() const parent = new Map() const child = new Map() const strictChild = new Map() const authorByKey = new Map() const tombRequests = [] const participantMsgs = [] for (const m of messages) { const k = m.key const v = m.value || {} const c = v.content if (!c) continue if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue } if (c.type === "calendarParticipant" && c.target) { participantMsgs.push({ target: c.target, author: v.author, on: c.on !== false, keyProof: typeof c.keyProof === "string" ? c.keyProof : "", code: typeof c.code === "string" ? c.code : "", ts: v.timestamp || m.timestamp || 0 }); continue } if (c.type === "calendar") { nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, seq: v.sequence || 0, c, author: v.author }) authorByKey.set(k, v.author) if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) } } } for (const t of tombRequests) { const targetAuthor = authorByKey.get(t.target) if (targetAuthor && t.author === targetAuthor) tomb.add(t.target) } for (const [k, node] of nodes.entries()) { const t = node.c.replaces if (!t) continue const orig = nodes.get(t) if (orig && orig.author === node.author) strictChild.set(t, k) } const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur } const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur } const ownerTip = new Map() for (const [k, n] of nodes.entries()) { const r = rootOf(k) const rn = nodes.get(r) if (!rn || n.author !== rn.author || tomb.has(k)) continue const prev = ownerTip.has(r) ? nodes.get(ownerTip.get(r)) : null if (!prev || n.seq > prev.seq || (n.seq === prev.seq && n.ts >= prev.ts)) ownerTip.set(r, k) } const contentTipOf = (root) => ownerTip.get(root) || root const roots = new Set() for (const id of nodes.keys()) roots.add(rootOf(id)) const joinAllowed = (root, pm) => { const rn = nodes.get(root) if (!rn) return false if (pm.author === rn.author || rn.c.tribeId) return true if (!rn.c.encryptedPayload && String(rn.c.status || "OPEN").toUpperCase() === "OPEN") return true return !!pm.keyProof && lookupKeys(root).some(k => keyProofFor(k, pm.author) === pm.keyProof) } const participantsByRoot = new Map() for (const pm of participantMsgs) { if (!nodes.has(pm.target)) continue const r = rootOf(pm.target) if (pm.on && !joinAllowed(r, pm)) continue if (!participantsByRoot.has(r)) participantsByRoot.set(r, new Map()) const m2 = participantsByRoot.get(r) const prev = m2.get(pm.author) if (!prev || pm.ts >= prev.ts) m2.set(pm.author, { on: pm.on, ts: pm.ts }) } const resolveParticipants = (root) => { const ownerNode = nodes.get(root) const oc = ownerNode ? ownerNode.c : {} const set = new Set(Array.isArray(oc.participants) ? oc.participants : []) for (const [au, st] of (participantsByRoot.get(root) || new Map())) { if (st.on) set.add(au); else set.delete(au) } return [...set] } const tipByRoot = new Map() for (const r of roots) tipByRoot.set(r, tipOf(r)) const contentTipByRoot = new Map() for (const r of roots) contentTipByRoot.set(r, contentTipOf(r)) return { tomb, nodes, parent, child, rootOf, tipOf, contentTipOf, tipByRoot, contentTipByRoot, resolveParticipants } } const buildCalendar = (node, rootId, participants) => { const c = node.c || {} if (c.type !== "calendar") return null const undec = c.encryptedPayload && c._decrypted === false return { key: node.key, rootId, title: undec ? "" : safeText(c.title), status: c.status || "OPEN", deadline: undec ? "" : (c.deadline || ""), tags: Array.isArray(c.tags) ? c.tags : [], mapUrl: typeof c.mapUrl === "string" ? c.mapUrl : "", author: node.author || c.author, participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []), invites: Array.isArray(c.invites) ? c.invites : [], createdAt: c.createdAt || new Date(node.ts).toISOString(), updatedAt: c.updatedAt || null, tribeId: c.tribeId || null, encrypted: !!undec, contentEncrypted: !!(c.encryptedPayload || c._decrypted === true) } } const calendarMembers = async (cal) => { const set = new Set([cal.author, ...(Array.isArray(cal.participants) ? cal.participants : [])].filter(Boolean)) if (cal.tribeId && tribesModel) { try { const t = await tribesModel.getTribeById(cal.tribeId) for (const m of (t && Array.isArray(t.members) ? t.members : [])) set.add(m) } catch (_) {} } return set } const isClosed = (calendar) => { if (calendar.status === "CLOSED") return true if (!calendar.deadline) return false return new Date(calendar.deadline).getTime() <= Date.now() } const collectCalendars = async () => { const ssbClient = await openSsb() const messages = await readAll(ssbClient) const idx = buildIndex(messages) await decryptIndexNodes(idx) const items = [] for (const rootId of idx.contentTipByRoot.keys()) { const contentTip = idx.contentTipByRoot.get(rootId) || rootId if (idx.tomb.has(contentTip)) continue const node = idx.nodes.get(contentTip) if (!node || node.c.type !== "calendar") continue const cal = buildCalendar(node, rootId, idx.resolveParticipants(rootId)) if (!cal) continue cal.isClosed = isClosed(cal) items.push(cal) } return items } return { type: "calendar", async ingestKeys() { await ingestOwnTribeKeys() }, async pruneOrphanKeys() { if (!ownCrypto || typeof ownCrypto.getAllRootIds !== "function") return 0 try { const ssbClient = await openSsb() const messages = await readAll(ssbClient) const tomb = buildValidatedTombstoneSet(messages) const all = ownCrypto.getAllRootIds() let removed = 0 for (const rid of all) { if (tomb.has(rid)) { try { ownCrypto.dropKey(rid); removed += 1 } catch (_) {} } } return removed } catch (_) { return 0 } }, async resolveRootId(id) { const ssbClient = await openSsb() const messages = await readAll(ssbClient) const idx = buildIndex(messages) const root = idx.rootOf(id) if (idx.tomb.has(idx.contentTipOf(root))) throw new Error("Not found") return root }, async resolveCurrentId(id) { const ssbClient = await openSsb() const messages = await readAll(ssbClient) const idx = buildIndex(messages) const tip = idx.contentTipOf(idx.rootOf(id)) if (idx.tomb.has(tip)) throw new Error("Not found") return tip }, async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline, mapUrl, tribeId }) { const ssbClient = await openSsb() const userId = ssbClient.id const now = new Date().toISOString() const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN" if (!firstDate) throw new Error("First date must be in the future") assertCalendarDates({ deadline, date: firstDate, until: hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? intervalDeadline : "" }) let plainContent = { type: "calendar", title: safeText(title), status: validStatus, deadline: deadline || "", tags: normalizeTags(tags), mapUrl: safeText(mapUrl), author: userId, participants: [userId], invites: [], createdAt: now, updatedAt: now, ...(tribeId ? { tribeId } : {}) } let calKey = null let content = plainContent if (tribeId) { content = await encryptIfTribe(plainContent) } else if (tribeCrypto) { calKey = ownCrypto.generateTribeKey() content = tribeCrypto.encryptContent(plainContent, [calKey], true) } const calMsg = await new Promise((resolve, reject) => { ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) }) const calendarId = calMsg.key if (calKey && tribeCrypto) { ownCrypto.setKey(calendarId, calKey, 1) try { const ssbKeys = require("../server/node_modules/ssb-keys") const boxedKey = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys) await new Promise((resolve) => { ssbClient.publish({ type: "tribe-keys", tribeId: calendarId, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve()) }) } catch (_) {} if (validStatus === "OPEN") { try { const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex") const inviteSalt = tribeCrypto.generateInviteSalt() const ek = tribeCrypto.encryptForInvite(calKey, pubCode, inviteSalt) const tipId = await this.resolveCurrentId(calendarId) const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) const dec = decryptCalendarRoot(item.content, calendarId) let updated = { type: "calendar", title: dec.title || "", status: validStatus, deadline: dec.deadline || "", tags: Array.isArray(dec.tags) ? dec.tags : [], mapUrl: dec.mapUrl || "", author: userId, participants: [userId], invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }], createdAt: dec.createdAt, updatedAt: new Date().toISOString(), replaces: tipId } updated = encryptStandalone(updated, calendarId) await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve())) } catch (_) {} } } let dateContent = { type: "calendarDate", calendarId, date: new Date(firstDate).toISOString(), label: safeText(firstDateLabel), author: userId, createdAt: new Date().toISOString(), ...(intervalWeekly ? { intervalWeekly: true } : {}), ...(intervalMonthly ? { intervalMonthly: true } : {}), ...(intervalYearly ? { intervalYearly: true } : {}), ...(hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) && (intervalDeadline || deadline) ? { intervalDeadline: intervalDeadline || deadline } : {}), ...(tribeId ? { tribeId } : {}) } if (tribeId) dateContent = await encryptIfTribe(dateContent) else if (calKey) dateContent = tribeCrypto.encryptContent(dateContent, [calKey], true) const dateMsg = await new Promise((resolve, reject) => { ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg)) }) if (firstNote && safeText(firstNote)) { let noteContent = { type: "calendarNote", calendarId, dateId: dateMsg.key, text: safeText(firstNote), author: userId, createdAt: new Date().toISOString(), ...(tribeId ? { tribeId } : {}) } if (tribeId) noteContent = await encryptIfTribe(noteContent) else if (calKey) noteContent = tribeCrypto.encryptContent(noteContent, [calKey], true) await new Promise((resolve, reject) => { ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg)) }) } return calMsg }, async updateCalendarById(id, data) { const tipId = await this.resolveCurrentId(id) const rootId = await this.resolveRootId(id) const ssbClient = await openSsb() const userId = ssbClient.id const item = await new Promise((resolve, reject) => { ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it)) }) if (!item || !item.content) throw new Error("Calendar not found") const oldDec = await decryptScoped(item.content, rootId) assertReadable(oldDec, "Calendar") if (item.author !== userId) throw new Error("Not the author") const nextDeadline = data.deadline !== undefined ? data.deadline : (oldDec.deadline || "") if (nextDeadline !== (oldDec.deadline || "")) assertCalendarDates({ deadline: nextDeadline }) let updated = { type: "calendar", title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""), status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"), deadline: nextDeadline, tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []), mapUrl: data.mapUrl !== undefined ? safeText(data.mapUrl) : (oldDec.mapUrl || ""), author: oldDec.author || userId, participants: oldDec.participants || [userId], invites: Array.isArray(oldDec.invites) ? oldDec.invites : [], ...(oldDec.tribeId ? { tribeId: oldDec.tribeId } : {}), createdAt: oldDec.createdAt, updatedAt: new Date().toISOString(), replaces: tipId } const closing = !oldDec.tribeId && String(oldDec.status || "OPEN").toUpperCase() === "OPEN" && updated.status === "CLOSED" if (closing) { updated.invites = updated.invites.filter(inv => !(inv && typeof inv === "object" && inv.public === true)) try { await rotateCalendarKey(rootId, (updated.participants || []).filter(p => p !== userId)) } catch (_) {} } if (updated.tribeId) updated = await encryptIfTribe(updated) else updated = encryptStandalone(updated, rootId) const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) return result }, async deleteCalendarById(id) { const tipId = await this.resolveCurrentId(id) const ssbClient = await openSsb() const userId = ssbClient.id const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) if (!item || !item.content) throw new Error("Calendar not found") const rootId = await this.resolveRootId(id) const dec = await decryptScoped(item.content, rootId) assertReadable(dec, "Calendar") if (item.author !== userId) throw new Error("Not the author") const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) }, async joinCalendar(calendarId) { const rootId = await this.resolveRootId(calendarId) const ssbClient = await openSsb() const userId = ssbClient.id const cal = await this.getCalendarById(rootId) if (!cal) throw new Error("Calendar not found") const participants = Array.isArray(cal.participants) ? cal.participants : [] if (participants.includes(userId)) return if (tribeCrypto && Array.isArray(cal.invites)) { const pub = cal.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain)) if (pub) return await this.joinByInvite(pub.code) } const content = { type: "calendarParticipant", target: rootId, on: true, createdAt: new Date().toISOString() } return new Promise((resolve, reject) => ssbClient.publish(content, (e, res) => e ? reject(e) : resolve(res))) }, async leaveCalendar(calendarId) { const rootId = await this.resolveRootId(calendarId) const ssbClient = await openSsb() const userId = ssbClient.id const cal = await this.getCalendarById(rootId) if (!cal) throw new Error("Calendar not found") if (cal.author === userId) throw new Error("Author cannot leave") const participants = Array.isArray(cal.participants) ? cal.participants : [] if (!participants.includes(userId)) return const content = { type: "calendarParticipant", target: rootId, on: false, createdAt: new Date().toISOString() } return new Promise((resolve, reject) => ssbClient.publish(content, (e, res) => e ? reject(e) : resolve(res))) }, async findCalendarByLinkText(linkSubstring) { if (!linkSubstring) return null const ssbClient = await openSsb() const messages = await readAll(ssbClient) const idx = buildIndex(messages) await decryptIndexNodes(idx) for (const node of idx.nodes.values()) { const c = node && node.c if (!c || c.type !== "calendarNote") continue if (typeof c.text === "string" && c.text.includes(linkSubstring)) { return c.calendarId || null } } return null }, async getCalendarById(id) { const ssbClient = await openSsb() const messages = await readAll(ssbClient) const idx = memoIndex('calendars', messages, buildIndex) await decryptIndexNodes(idx) let root = id while (idx.parent.has(root)) root = idx.parent.get(root) const contentTip = idx.contentTipOf(root) if (idx.tomb.has(contentTip)) return null const node = idx.nodes.get(contentTip) if (!node || node.c.type !== "calendar") return null const cal = buildCalendar(node, root, idx.resolveParticipants(root)) if (!cal) return null cal.isClosed = isClosed(cal) return calCollab.fold(cal, await collectCalendars()) }, async listAll({ filter = "all", viewerId } = {}) { const ssbClient = await openSsb() const uid = viewerId || ssbClient.id let list = calCollab.visibleThenCollapsed(await collectCalendars(), uid) try { await ensureMemberKeys(ssbClient, await readAll(ssbClient), list) } catch (_) {} if (filter === "mine") list = list.filter(c => c.author === uid) else if (filter === "recent") { const now = Date.now() list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000) } else if (filter === "open") list = list.filter(c => !c.isClosed) else if (filter === "closed") list = list.filter(c => c.isClosed) return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) }, async addDate(calendarId, date, label, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline) { const ssbClient = await openSsb() const userId = ssbClient.id const rootId = await this.resolveRootId(calendarId) const cal = await this.getCalendarById(rootId) if (!cal) throw new Error("Calendar not found") if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar") if (!date) throw new Error("Date must be in the future") const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) assertCalendarDates({ deadline: cal.deadline || "", date, until: hasInterval ? intervalDeadline : "" }) const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : "" if (!(await calendarMembers(cal)).has(userId)) { if (cal.tribeId || cal.status !== "OPEN") throw new Error("Only participants can add dates") await this.joinCalendar(rootId) } let dateContent = { type: "calendarDate", calendarId: rootId, date: new Date(date).toISOString(), label: safeText(label), author: userId, createdAt: new Date().toISOString(), ...(intervalWeekly ? { intervalWeekly: true } : {}), ...(intervalMonthly ? { intervalMonthly: true } : {}), ...(intervalYearly ? { intervalYearly: true } : {}), ...(ruleDeadline ? { intervalDeadline: ruleDeadline } : {}), ...(cal.tribeId ? { tribeId: cal.tribeId } : {}) } if (cal.tribeId) dateContent = await encryptIfTribe(dateContent) else dateContent = encryptStandalone(dateContent, rootId) const msg = await new Promise((resolve, reject) => { ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m)) }) return [msg] }, async getDatesForCalendar(calendarId) { const rootId = await this.resolveRootId(calendarId) const cal = await this.getCalendarById(rootId) const calDeadline = cal && cal.deadline ? cal.deadline : "" const pubKey = cal ? tryDecryptPublicInviteKey(cal.invites) : null const members = cal ? await calendarMembers(cal) : new Set() const ssbClient = await openSsb() const messages = await readAll(ssbClient) const authorByKey = new Map() for (const m of messages) authorByKey.set(m.key, (m.value || {}).author) const tombstoned = new Set() for (const m of messages) { const c = (m.value || {}).content if (c && c.type === "tombstone" && c.target) { const targetAuthor = authorByKey.get(c.target) if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target) } } const dates = [] for (const m of messages) { if (tombstoned.has(m.key)) continue const v = m.value || {} const c = v.content if (!c || c.type !== "calendarDate") continue if (c.calendarId !== rootId) continue if (!members.has(v.author)) continue let dec = c if (c.encryptedPayload && tribeCrypto) { if (c.tribeId && tribesModel) { const r = await tribeCrypto.decryptFromTribe(c, tribesModel) dec = r && !r._undecryptable ? r : c if (r && r._undecryptable) continue } else { const keys = [...(lookupKeys(c.calendarId) || []), ...(pubKey ? [pubKey] : [])] if (!keys.length) continue const r = tribeCrypto.decryptContent(c, keys.map(k => [k])) if (!r || r._undecryptable) continue dec = r } } const baseEntry = { key: m.key, calendarId: dec.calendarId || c.calendarId, label: dec.label || "", author: v.author || dec.author, createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString() } const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly) const ruleDeadline = dec.intervalDeadline || calDeadline if (hasInterval && ruleDeadline) { const occurrences = boundedOccurrences(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly) for (const occ of occurrences) { dates.push({ ...baseEntry, date: occ.toISOString() }) } } else { dates.push({ ...baseEntry, date: dec.date }) } } const owner = cal ? cal.author : null const kept = [...dates.filter(d => d.author === owner), ...dates.filter(d => d.author !== owner)].slice(0, MAX_DATES_PER_VIEW) kept.sort((a, b) => new Date(a.date) - new Date(b.date)) return kept }, async deleteDate(dateId, calendarId) { const ssbClient = await openSsb() const userId = ssbClient.id const rootId = await this.resolveRootId(calendarId) const cal = await this.getCalendarById(rootId) if (!cal) throw new Error("Calendar not found") const messages = await readAll(ssbClient) const authorByKey = new Map() for (const m of messages) authorByKey.set(m.key, (m.value || {}).author) const tombstoned = new Set() for (const m of messages) { const c = (m.value || {}).content if (c && c.type === "tombstone" && c.target) { const targetAuthor = authorByKey.get(c.target) if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target) } } let dateAuthor = null for (const m of messages) { if (m.key !== dateId) continue const c = (m.value || {}).content if (!c || c.type !== "calendarDate") continue if (tombstoned.has(m.key)) break let dec = c if (c.encryptedPayload && tribeCrypto && tribesModel) { const r = await tribeCrypto.decryptFromTribe(c, tribesModel) if (r && !r._undecryptable) dec = r } dateAuthor = dec.author || (m.value || {}).author break } if (!dateAuthor) throw new Error("Date not found") if (dateAuthor !== userId && cal.author !== userId) throw new Error("Not authorized") for (const m of messages) { const c = (m.value || {}).content if (!c || c.type !== "calendarNote") continue if (tombstoned.has(m.key)) continue if (c.dateId !== dateId) continue await new Promise((resolve, reject) => { ssbClient.publish({ type: "tombstone", target: m.key, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve()) }) } return new Promise((resolve, reject) => { ssbClient.publish({ type: "tombstone", target: dateId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve()) }) }, async addNote(calendarId, dateId, text) { const ssbClient = await openSsb() const userId = ssbClient.id const rootId = await this.resolveRootId(calendarId) const cal = await this.getCalendarById(rootId) if (!cal) throw new Error("Calendar not found") if (!cal.participants.includes(userId)) throw new Error("Only participants can add notes") let noteContent = { type: "calendarNote", calendarId: rootId, dateId, text: safeText(text), author: userId, createdAt: new Date().toISOString(), ...(cal.tribeId ? { tribeId: cal.tribeId } : {}) } if (cal.tribeId) noteContent = await encryptIfTribe(noteContent) else noteContent = encryptStandalone(noteContent, rootId) return new Promise((resolve, reject) => { ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg)) }) }, async deleteNote(noteId) { const ssbClient = await openSsb() const userId = ssbClient.id const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it))) if (!item || !item.content) throw new Error("Note not found") if (item.author !== userId) throw new Error("Not the author") return new Promise((resolve, reject) => { ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg)) }) }, async getNotesForDate(calendarId, dateId) { const rootId = await this.resolveRootId(calendarId) const ssbClient = await openSsb() const messages = await readAll(ssbClient) const authorByKey = new Map() for (const m of messages) authorByKey.set(m.key, (m.value || {}).author) const tombstoned = new Set() for (const m of messages) { const c = (m.value || {}).content if (c && c.type === "tombstone" && c.target) { const targetAuthor = authorByKey.get(c.target) if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target) } } const notes = [] let pubKey for (const m of messages) { const v = m.value || {} const c = v.content if (!c || c.type !== "calendarNote") continue if (tombstoned.has(m.key)) continue if (c.calendarId !== rootId || (dateId != null && c.dateId !== dateId)) continue let dec = c if (c.encryptedPayload) { let r = await decryptScoped(c, rootId) if ((!r || r._undecryptable) && !c.tribeId && tribeCrypto) { if (pubKey === undefined) { const cal = await this.getCalendarById(rootId).catch(() => null) pubKey = cal ? tryDecryptPublicInviteKey(cal.invites) : null } if (pubKey) r = tribeCrypto.decryptContent(c, [[pubKey]]) } if (r && !r._undecryptable) dec = r else continue } notes.push({ key: m.key, calendarId: dec.calendarId || c.calendarId, dateId: dec.dateId || c.dateId, text: dec.text || "", author: v.author || dec.author, createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString() }) } notes.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) return notes }, async checkDueReminders() { if (!pmModel) return const ssbClient = await openSsb() const messages = await readAll(ssbClient) const now = Date.now() const sentMarkers = new Set() for (const m of messages) { const c = (m.value || {}).content if (!c || c.type !== "calendarReminderSent") continue const sig = c.occurrence ? `${c.calendarId}::${c.dateId}::${c.occurrence}` : `${c.calendarId}::${c.dateId}` sentMarkers.add(sig) } const authorByKey = new Map() for (const m of messages) authorByKey.set(m.key, (m.value || {}).author) const tombstoned = new Set() for (const m of messages) { const c = (m.value || {}).content if (c && c.type === "tombstone" && c.target) { const targetAuthor = authorByKey.get(c.target) if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target) } } const calendarInfo = new Map() const dueByCalendar = new Map() const dated = messages.filter(m => !tombstoned.has(m.key) && ((m.value || {}).content || {}).type === "calendarDate") const decoded = await Promise.all(dated.map(async (m) => { const c = m.value.content if (!(c.encryptedPayload && tribeCrypto && tribesModel)) return c const r = await tribeCrypto.decryptFromTribe(c, tribesModel) return !r || r._undecryptable ? null : r })) for (let i = 0; i < dated.length; i++) { const m = dated[i] const v = m.value || {} const c = v.content const dec = decoded[i] if (!dec || !dec.date) continue const calId = c.calendarId let info = calendarInfo.get(calId) if (info === undefined) { try { const cc = await this.getCalendarById(calId) info = cc ? { deadline: cc.deadline || "", members: await calendarMembers(cc) } : null } catch (_) { info = null } calendarInfo.set(calId, info) } if (!info || !info.members.has(v.author)) continue const calDeadline = info.deadline const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly) const ruleDeadline = dec.intervalDeadline || calDeadline const occurrences = (hasInterval && ruleDeadline) ? boundedOccurrences(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly) : [new Date(dec.date)] for (const occ of occurrences) { if (occ.getTime() > now) continue const occIso = occ.toISOString() const sig = hasInterval ? `${calId}::${m.key}::${occIso}` : `${calId}::${m.key}` if (sentMarkers.has(sig)) continue const entry = { key: m.key, calendarId: calId, date: occIso, label: dec.label || "", recurring: hasInterval } const list = dueByCalendar.get(calId) || [] list.push(entry) dueByCalendar.set(calId, list) } } const publishMarker = (calendarId, dateId, occurrence) => new Promise((resolve, reject) => { const payload = { type: "calendarReminderSent", calendarId, dateId, sentAt: new Date().toISOString() } if (occurrence) payload.occurrence = occurrence ssbClient.publish(payload, (err) => err ? reject(err) : resolve()) }) for (const [calendarId, list] of dueByCalendar.entries()) { try { list.sort((a, b) => new Date(b.date) - new Date(a.date)) const primary = list[0] const cal = await this.getCalendarById(calendarId) if (!cal) continue const participants = cal.participants.filter(p => typeof p === "string" && p.length > 0) if (participants.length > 0) { const notesForDay = await this.getNotesForDate(calendarId, primary.key) const notesBlock = notesForDay.length > 0 ? notesForDay.map(n => ` - ${n.text}`).join("\n\n") : " (no notes)" const subject = `Calendar Reminder: ${cal.title}` const text = `Reminder from: ${cal.author}\n` + `Title: ${cal.title}\n` + `Date: ${primary.label || primary.date}\n\n` + `Notes for this day:\n\n${notesBlock}\n\n` + `Visit Calendar: /calendars/${cal.rootId}` const chunkSize = 6 for (let i = 0; i < participants.length; i += chunkSize) { await pmModel.sendMessage(participants.slice(i, i + chunkSize), subject, text) } } for (const dd of list) { try { await publishMarker(calendarId, dd.key, dd.recurring ? dd.date : null) } catch (_) {} } } catch (_) {} } }, async generateInvite(calendarId, opts = {}) { const ssbClient = await openSsb() const userId = ssbClient.id const cal = await this.getCalendarById(calendarId) if (!cal) throw new Error("Calendar not found") if (cal.author !== userId) throw new Error("Only the author can generate invites") const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex") let invite = code const calKey = !cal.tribeId ? lookupKey(cal.rootId) : null if (tribeCrypto && calKey) { const inviteSalt = tribeCrypto.generateInviteSalt() const ek = tribeCrypto.encryptForInvite(calKey, code, inviteSalt) invite = opts.public ? { code, ek, salt: inviteSalt, gen: lookupGen(cal.rootId), public: true } : { ch: tribeCrypto.hashInviteCode(code, inviteSalt), ek, salt: inviteSalt, gen: lookupGen(cal.rootId) } } if (opts.public && typeof invite !== "object") invite = { code, public: true } const tipId = await this.resolveCurrentId(calendarId) const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) const dec = await decryptScoped(item.content, cal.rootId) assertReadable(dec, "Calendar") const invites = [...(Array.isArray(dec.invites) ? dec.invites : []), invite] let updated = { type: "calendar", title: dec.title || "", status: dec.status || "OPEN", deadline: dec.deadline || "", tags: Array.isArray(dec.tags) ? dec.tags : [], mapUrl: dec.mapUrl || "", author: dec.author, participants: Array.isArray(dec.participants) ? dec.participants : [userId], invites, ...(dec.tribeId ? { tribeId: dec.tribeId } : {}), createdAt: dec.createdAt, updatedAt: new Date().toISOString(), replaces: tipId } if (updated.tribeId) updated = await encryptIfTribe(updated) else updated = encryptStandalone(updated, cal.rootId) await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) return code }, async getOpenInvite(calendarId) { return openInviteOf(await this.getCalendarById(calendarId).catch(() => null)) }, async generateOpenInvite(calendarId) { const cal = await this.getCalendarById(calendarId) if (!cal) throw new Error("Calendar not found") const existing = (Array.isArray(cal.invites) ? cal.invites : []).find(inv => typeof inv === "object" && inv.public === true) if (existing) throw new Error("An open invitation already exists") return this.generateInvite(calendarId, { public: true }) }, async removeOpenInvite(calendarId) { const ssbClient = await openSsb() const userId = ssbClient.id const cal = await this.getCalendarById(calendarId) if (!cal) throw new Error("Calendar not found") if (cal.author !== userId) throw new Error("Only the author can remove invites") const tipId = await this.resolveCurrentId(calendarId) const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) const dec = await decryptScoped(item.content, cal.rootId) assertReadable(dec, "Calendar") const invites = (Array.isArray(dec.invites) ? dec.invites : []).filter(inv => !(typeof inv === "object" && inv.public === true)) let updated = { type: "calendar", title: dec.title || "", status: dec.status || "OPEN", deadline: dec.deadline || "", tags: Array.isArray(dec.tags) ? dec.tags : [], mapUrl: dec.mapUrl || "", author: dec.author, participants: Array.isArray(dec.participants) ? dec.participants : [userId], invites, ...(dec.tribeId ? { tribeId: dec.tribeId } : {}), createdAt: dec.createdAt, updatedAt: new Date().toISOString(), replaces: tipId } if (updated.tribeId) updated = await encryptIfTribe(updated) else updated = encryptStandalone(updated, cal.rootId) await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) if (!dec.tribeId) await rotateCalendarKey(cal.rootId, Array.isArray(cal.participants) ? cal.participants : []) }, async joinByInvite(code) { const ssbClient = await openSsb() const userId = ssbClient.id const calendars = await collectCalendars() let matched = null let matchedInvite = null for (const cal of calendars) { const invs = Array.isArray(cal.invites) ? cal.invites : [] for (const inv of invs) { if (typeof inv === "string" && inv === code) { matched = cal; matchedInvite = inv; break } if (typeof inv === "object" && inv && (inv.code === code || (inv.ch && tribeCrypto && inv.ch === tribeCrypto.hashInviteCode(code, inv.salt)))) { matched = cal; matchedInvite = inv; break } } if (matched) break } if (!matched) throw new Error("Invalid or expired invite code") if (matched.participants.includes(userId)) throw new Error("Already a participant") if (typeof matchedInvite === "object" && matchedInvite.ch && tribeCrypto && matchedInvite.ek) { let probeKey = null try { probeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt) } catch (_) {} const messages = await readAll(await openSsb()) const spent = messages.some(m => { const c = m.value && m.value.content return c && c.type === "calendarParticipant" && c.on !== false && c.target === matched.rootId && c.code === matchedInvite.ch && m.value.author !== userId && (!probeKey || keyProofFor(probeKey, m.value.author) === c.keyProof) }) if (spent) throw new Error("Invite already used") } let calKey = null if (tribeCrypto && typeof matchedInvite === "object") { if (matchedInvite.ekChain) { const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3) if (Array.isArray(chain) && chain.length) { for (const entry of chain) { if (Array.isArray(entry.keys) && entry.keys.length) { tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length) } else if (entry.key) { tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1) } } calKey = chain[0].key } } else if (matchedInvite.ek) { calKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, matchedInvite.salt) ownCrypto.setKey(matched.rootId, calKey, matchedInvite.gen || 1) } } const tipId = await this.resolveCurrentId(matched.rootId) const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it))) const dec = await decryptScoped(item.content, matched.rootId) assertReadable(dec, "Calendar") const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true const invites = isPublicInvite ? (Array.isArray(dec.invites) ? dec.invites : []) : (Array.isArray(dec.invites) ? dec.invites : []).filter(inv => { if (typeof inv === "string") return inv !== code return inv.code !== code }) let updated = { type: "calendar", title: dec.title || "", status: dec.status || "OPEN", deadline: dec.deadline || "", tags: Array.isArray(dec.tags) ? dec.tags : [], mapUrl: dec.mapUrl || "", author: dec.author, participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId], invites, ...(dec.tribeId ? { tribeId: dec.tribeId } : {}), createdAt: dec.createdAt, updatedAt: new Date().toISOString(), replaces: tipId } if (updated.tribeId) updated = await encryptIfTribe(updated) else updated = encryptStandalone(updated, matched.rootId) await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) if (tribeCrypto && calKey) { try { const ssbKeys = require("../server/node_modules/ssb-keys") const memberKeys = {} try { memberKeys[userId] = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys) } catch (_) {} if (matched.author && matched.author !== userId) { try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(calKey, matched.author, ssbKeys) } catch (_) {} } if (Object.keys(memberKeys).length) { await new Promise((resolve) => { ssbClient.publish({ type: "tribe-keys", tribeId: matched.rootId, generation: lookupGen(matched.rootId) || 1, memberKeys }, () => resolve()) }) } } catch (_) {} } await new Promise((resolve, reject) => ssbClient.publish({ type: "calendarParticipant", target: matched.rootId, on: true, ...(calKey ? { keyProof: keyProofFor(calKey, userId) } : {}), ...(matchedInvite && typeof matchedInvite === "object" && matchedInvite.ch ? { code: matchedInvite.ch } : {}), createdAt: new Date().toISOString() }, (e, res) => e ? reject(e) : resolve(res))) return matched.rootId } } }