OASIS_MOBILE/src/models/calendars_model.js
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00

1193 lines
54 KiB
JavaScript

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
}
}
}