Skip to content

Shared Packages

The packages/ workspaces hold everything shared between the two apps. db has its own page; this page covers the other five. The theme running through all of them: define a thing once, import it everywhere.

The client/server contract. Every data shape is a Zod schema; the TypeScript type is inferred from it, so validation and types never drift. Both apps import from @musubi/types — there is no duplicated Event interface anywhere.

File Exports
event.ts EventSchema, Eventstart/end via z.coerce.date(), calendars: string[], originCalendarID nullish, recurrence nullish
calendar.ts CalendarSchema, Calendar, minimal CalendarInvitePreview, providerFlavor() (detects "apple" from iCloud CalDAV)
federation.ts Member-token lifetime, timestamp parsing, proactive-rotation decision
settings.ts SettingsSchema, Settings, CalendarView enum
invite.ts, user.ts, google.ts their schemas + inferred types
errors.ts BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError (each has a kind for HTTP mapping)
permissions.ts CalendarRole, CalendarAction, and can(role, action)

Permissions are the single source of truth

Section titled “Permissions are the single source of truth”

permissions.ts is worth calling out because both sides depend on it:

const PERMISSIONS: Record<CalendarRole, CalendarAction[]> = {
owner: ["editCalendar", "deleteCalendar", "manageMembers", "editEvents", "invite"],
editor: ["editEvents", "invite"],
viewer: [],
};
export function can(role, action): boolean { … }

The server calls can() (via assertCan) to gate handlers; the client calls the same can() to hide or disable UI the user isn’t allowed to use. If you add an action, update both the CalendarAction type and the PERMISSIONS record — nothing else.

Recurrence, date and calendar layout helpers — pure logic, no UI. The React Native and DOM renderers stay in apps/client and apps/web, while both clients share their range and segmentation rules through this package.

Recurrence is stored on events.recurrence as iCalendar text — a bare RRULE for simple cases, or multi-line RRULE + EXDATE/RDATE when there are exceptions. It is expanded into individual occurrences at read time, never stored as instances.

recurrence.ts exports:

Function Purpose
splitRecurrence(str) { rrule, extras[] } (separates the rule from EXDATE/RDATE lines)
joinRecurrence(rrule, extras) the inverse
excludeOccurrence(rec, date) add an EXDATE — “delete just this one occurrence”
endSeriesBefore(rec, date) set UNTIL just before a date — “delete this and future”
expandRecurringEvents(events, start, end) expand rules into occurrences within a window; occurrence ids are "<id>_<startMs>" for stable React keys

Parsed rules are memoised in a Map keyed by recurrence@dtstart so swiping the calendar doesn’t re-parse. datetime.ts holds the all-day handling (eventDay() reinterprets a UTC-midnight all-day date in the local frame); interfaces.ts has the minimal ICalendarEventBase shape the expansion works against.

The @musubi/calendar/layout subpath keeps layout consumers separate from the RRULE parser:

Module Shared responsibility
month-grid.ts, ranges.ts six-week Month grids, visible ranges, week starts and calendar-day navigation
day-buckets.ts local/UTC day keys, day buckets and all-day continuation segments
all-day-spans.ts continuous row spans and stable lane assignment
day-segments.ts, overlaps.ts per-day clipping and transitive overlap columns

Musubi’s internal all-day end is the inclusive final calendar date. Google, Microsoft and CalDAV adapters convert their exclusive provider end at the API boundary; UI layout must not subtract another day.

The Better Auth configuration (lib/auth.ts): the Drizzle/Postgres adapter, session + bearer + Expo plugins, email/password, and Google OAuth (accessType: "offline" for refresh tokens; account linking across different emails enabled).

The one hook contributors hit most: on user creation, a default personal calendar is auto-created (isDefault: true, undeletable). Every user — email or social — gets one.

Environment loading with a fail-fast helper:

function envOrThrow(key: string): string {
const value = process.env[key];
if (!value) throw new Error(`Missing value from ENV on KEY: ${key}`);
return value;
}

Required at boot (server won’t start without them): DATABASE_URL, ENVIRONMENT, BETTER_AUTH_URL, BETTER_AUTH_SECRET. Optional, capability-gated: SMTP, Google/Microsoft OAuth credentials, CALDAV_ENC_KEY. The pattern is deliberate — a missing optional key disables one feature instead of crashing the server, so you can run a minimal stack locally. SMTP is checked once before listening; restart the API after changing it. See Running Locally for the full variable table.

LOG_LEVEL is optional and defaults to info; invalid values fail fast at boot. The config package also exports the process-wide structured logger, shared by the API and auth hooks.

Transactional email: sendEmail() (a nodemailer SMTP transport built from config.smtp) plus the HTML templates for password reset, deletion, verification, address changes, and email sign-in codes. @musubi/auth uses the send/template surface; apps/api calls initializeEmailCapability() before listening and serves its instant canSendEmail() snapshot through capability discovery. The package depends only on @musubi/config.

flowchart TD
    client[apps/client]
    web[apps/web]
    api[apps/api]
    auth[packages/auth]
    db[packages/db]
    calendar[packages/calendar]
    types[packages/types]
    config[packages/config]
    emails[packages/emails]

    client --> types
    client --> calendar
    web --> types
    web --> calendar
    api --> types
    api --> calendar
    api --> db
    api --> auth
    api --> emails
    auth --> db
    db --> types
    api --> config
    db --> config
    auth --> config
    auth --> emails
    emails --> config

packages/types and packages/config are the leaves everything leans on — change them thoughtfully.