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 logic and date helpers — pure logic, no UI (the calendar views are custom in apps/client/components/cal/). Both apps expand RRULEs 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 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. Optional, gated at use: 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. 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 getPasswordResetHtml() and getDeleteAccountHtml(). It lives in its own package so @musubi/auth can send reset/delete mails without reaching back into apps/api — that cross-package relative import broke the deployed image (the package resolves to node_modules, not a sibling of apps/api). Depends only on @musubi/config.

flowchart TD
    client[apps/client]
    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
    api --> types
    api --> calendar
    api --> db
    api --> auth
    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.