Multi-tenant calendar aggregation service that synchronizes events from Google Calendar, Microsoft Outlook, and iCal feeds — with Slack bot integration and unified iCal feed output.
- Multi-provider sync — Google Calendar, Microsoft Outlook, and remote iCal feeds
- Slack integration —
/ajustes(settings) and/calendario(view events) commands - Unified iCal feeds — Subscribe from any calendar app via a personal feed URL
- Automatic sync — Configurable cron-based synchronization (default: every 15 minutes)
- Per-user OAuth 2.0 — Each Slack user authenticates independently with calendar providers
- Encrypted token storage — AES-256-GCM with PBKDF2 key derivation
- Runtime: Node.js 22
- Server: Express
- Database: SQLite via better-sqlite3
- Slack: Slack Bolt (Socket Mode)
- Google API: googleapis
- Microsoft API: @azure/msal-node, @microsoft/microsoft-graph-client
- Calendar parsing: ical.js
- Date/time: Luxon
- Scheduling: cron
- Linting: ESLint 9 (flat config)
- Node.js 22+ (see
.nvmrc) - Google Cloud OAuth 2.0 credentials
- Azure app registration (for Microsoft/Outlook)
- Slack App with Socket Mode enabled
# Clone repository
git clone <repo-url>
cd orbitant-calendar-sync
# Install dependencies
npm install
# Configure environment variables
cp .env.example .env
# Edit .env with your credentials
# Start server
npm startEdit the .env file with the following variables (see .env.example for reference):
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID |
OAuth 2.0 client ID from Google Cloud Console |
GOOGLE_CLIENT_SECRET |
OAuth 2.0 client secret |
GOOGLE_REDIRECT_URI |
Redirect URI (e.g., http://localhost:3000/auth/google/callback) |
GOOGLE_SCOPES |
Comma-separated scopes (calendar.readonly, userinfo.email) |
GOOGLE_CALENDAR_ID |
Calendar ID to sync (default: primary) |
| Variable | Description |
|---|---|
AZURE_CLIENT_ID |
Application (client) ID from Azure Portal > App registrations |
AZURE_CLIENT_SECRET |
Client secret value |
AZURE_TENANT_ID |
Directory (tenant) ID — use common for multi-tenant |
AZURE_REDIRECT_URI |
Redirect URI (e.g., http://localhost:3000/auth/azure/callback) |
AZURE_SCOPES |
Comma-separated Microsoft Graph scopes |
| Variable | Description |
|---|---|
SLACK_APP_TOKEN |
App-level token (xapp-...) with connections:write scope |
SLACK_BOT_TOKEN |
Bot user OAuth token (xoxb-...) |
SLACK_ADMINS |
Comma-separated Slack user IDs with admin privileges |
SLACK_CHANNEL_ID |
Channel ID for notifications (optional) |
| Variable | Description |
|---|---|
PORT |
Server port (default: 3000) |
BASE_URL |
Public URL used to generate iCal feed links |
DATABASE_PATH |
SQLite database location (default: ./data/calendar.db) |
TOKEN_ENCRYPTION_KEY |
64-char hex key for encrypting OAuth tokens (required) |
Generate the encryption key with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"| Variable | Description |
|---|---|
SYNC_CRON |
Cron expression for auto-sync (default: 0 */15 * * * * — every 15 min) |
SYNC_ON_STARTUP |
Whether to sync on server start (default: true) |
MAINTENANCE_MODE |
Set to true to pause all sync jobs and gate Slack commands |
| Command | Description |
|---|---|
/ajustes |
Manage connected accounts, calendar sources, and feed URL |
/calendario |
View today's and tomorrow's events |
| Endpoint | Description |
|---|---|
GET /health |
Service health check |
GET /feed/:token/orbitando.ics |
Unified iCal feed for a user |
GET /auth/google/callback |
Google OAuth callback |
GET /auth/azure/callback |
Microsoft OAuth callback |
After connecting your accounts via /ajustes, get your personal iCal feed URL and subscribe from:
- Google Calendar
- Apple Calendar
- Microsoft Outlook
- Any iCal-compatible client
src/
├── index.js # Express server, routes, startup
├── config/
│ └── database.js # SQLite initialization and migrations
├── database/
│ └── schema.sql # Database schema
├── jobs/
│ └── SyncScheduler.js # Cron-based sync scheduling
├── models/
│ ├── Event.js # Calendar events
│ ├── FeedToken.js # iCal feed tokens per user
│ ├── OAuthToken.js # Encrypted OAuth tokens
│ ├── Source.js # Calendar sources
│ └── SyncState.js # Sync status per source
├── providers/
│ ├── BaseProvider.js # Abstract base class
│ ├── GoogleCalendarProvider.js # Google Calendar API v3
│ ├── MicrosoftCalendarProvider.js # Microsoft Graph API
│ ├── ICalRemoteProvider.js # Remote .ics URL fetching
│ └── ICalLocalProvider.js # Local .ics file reading
├── scripts/
│ └── generate-tokens.js # Legacy token script (deprecated)
├── services/
│ ├── CalendarAggregator.js # Provider factory and cache
│ ├── google-calendar.js # Google Calendar API service
│ ├── microsoft-calendar.js # Microsoft Graph API service
│ ├── ICalGenerator.js # iCal feed generation
│ └── SyncService.js # Sync orchestration (singleton)
├── slack/
│ ├── app.js # Slack Bolt app setup
│ ├── actions/
│ │ ├── oauth.js # Google OAuth flow helpers
│ │ ├── microsoft-oauth.js # Microsoft OAuth flow helpers
│ │ └── sources.js # Source management actions
│ ├── commands/
│ │ ├── ajustes.js # /ajustes command
│ │ └── calendario.js # /calendario command
│ ├── middleware/
│ │ └── maintenanceMiddleware.js # Maintenance mode gating
│ └── modals/
│ └── sourceModal.js # Add/edit iCal source modal
└── utils/
├── crypto.js # AES-256-GCM encryption with PBKDF2
├── eventNormalizer.js # Date/event normalization utilities
├── maintenance.js # Maintenance mode management
└── timezone.js # Timezone utilities
All calendar sources extend BaseProvider, which defines the interface:
initialize()— Authenticate and connect to the calendar APIfetchEvents(options)— Full event fetchsync(syncState)— Incremental sync (returns events, deletions, new sync state)normalizeEvent(rawEvent)— Convert provider-specific format to unified schemasupportsIncrementalSync()— Whether provider supports delta sync
Implementations:
GoogleCalendarProvider— Google Calendar API v3, sync tokens for incremental updatesMicrosoftCalendarProvider— Microsoft Graph API, delta queriesICalRemoteProvider— Fetches remote.icsURLs, uses ETags for cachingICalLocalProvider— Reads local.icsfiles, uses mtime for change detection
- SyncService (singleton) — Orchestrates
syncAll(),syncSource(id),syncUserSources(slackUserId) - CalendarAggregator — Factory that creates and caches provider instances per source
- GoogleCalendarService — Google Calendar API wrapper with OAuth token management and auto-refresh
- MicrosoftCalendarService — Microsoft Graph API wrapper with MSAL token management
- ICalGenerator — Generates iCalendar output from stored events
All models use better-sqlite3 directly (no ORM), with static methods (Active Record style):
| Model | Table | Purpose |
|---|---|---|
Source |
sources |
Calendar sources per user (google, microsoft, ical_remote, ical_local) |
Event |
events |
Unified calendar events with source_id FK |
OAuthToken |
oauth_tokens |
Encrypted OAuth tokens per Slack user + provider |
SyncState |
sync_state |
Sync tokens, ETags, error states per source |
FeedToken |
feed_tokens |
Unique iCal feed URL tokens per user |
- Commands:
/ajustes(settings UI),/calendario(event viewer) - Actions: OAuth flow initiation, source CRUD (add, edit, toggle, delete)
- Modals: Source add/edit modal with name, type, URL, and color fields
- Middleware: Maintenance mode gating for commands and actions
SQLite schema (5 tables):
| Table | Key Constraints |
|---|---|
sources |
Type CHECK (google, microsoft, ical_remote, ical_local), indexed on slack_user_id |
events |
FK → sources (CASCADE), UNIQUE on (source_id, external_id) |
sync_state |
FK → sources (CASCADE), UNIQUE on source_id |
oauth_tokens |
UNIQUE on (slack_user_id, provider), provider CHECK (google, microsoft) |
feed_tokens |
UNIQUE on both slack_user_id and token |
The database is automatically initialized on startup from src/database/schema.sql. Migrations run in src/config/database.js.
OAuth tokens are encrypted before storage using AES-256-GCM with PBKDF2 key derivation:
- Algorithm: AES-256-GCM (authenticated encryption)
- Key derivation: PBKDF2 with SHA-512, 100,000 iterations, unique 64-byte salt per token
- Storage format:
salt:iv:authTag:ciphertext(hex-encoded) - Master key: 32-byte hex string from
TOKEN_ENCRYPTION_KEYenv var
- All source operations are scoped to the authenticated Slack user
- User ownership is validated before source modifications
- iCal feeds use unique, unguessable tokens per user
- Admin actions (manual sync) restricted to
SLACK_ADMINSuser IDs
# Build and run
docker-compose up -d
# View logs
docker-compose logs -f
# Stop
docker-compose downThe container maps port 3030 (host) → 3000 (container). SQLite data is persisted via the ./data volume mount.
The Docker image is based on node:22 (Debian) with libsqlite3-dev for native SQLite bindings.
Maintenance mode pauses all sync jobs and can gate Slack commands:
- Enable via env var: Set
MAINTENANCE_MODE=true(cannot be toggled at runtime) - Enable via flag file: Creates
/tmp/maintenance.flag(can be toggled at runtime) - Priority: Environment variable takes precedence over flag file
- Admin access: Admins listed in
SLACK_ADMINSretain access during maintenance
# Start with auto-reload (Node 22 --watch mode)
npm run dev
# Lint
npm run lint
npm run lint:fix
# Run legacy token generation script (deprecated — use Slack OAuth flow instead)
npm run authMIT — See LICENSE file for details.