Privacy-preserving meeting scheduling with end-to-end encrypted participant submissions. Like when2meet, but the server can't see your availability.
This project lets an organizer create a room and share a participant link. Participants fill an availability grid manually or from Google Calendar. The server can read room metadata, but participant names and availability stay encrypted, and decryption, overlap calculation, and Google Calendar fetching all happen client-side in the browser.
Current design:
- organizer and participant access both start from secret capability links
- capability links are exchanged once for short-lived bearer sessions
- the browser immediately scrubs capability material from the visible URL
- participant submissions use a versioned
XChaCha20-Poly1305envelope - overlap is computed client-side after decrypting submissions locally
- Google Calendar imports use an in-memory access token that is revoked immediately after import
Protected data:
- participant display names
- participant availability by slot
- submission edit capability
Server-readable data:
- room id
- room title
- room timezone
- room start and end dates
- room expiry
Access model:
- organizer links grant room administration capability
- participant links grant room read/write capability plus the fragment decryption key
- bootstrap capabilities are not meant to be reused on API requests
- all post-bootstrap API requests use
Authorization: Bearer <session>
Current crypto implementation:
src/lib/crypto.tsencrypts submissions withXChaCha20-Poly1305- ciphertext is wrapped in a versioned envelope:
{
"version": 2,
"algorithm": "xchacha20poly1305-ietf",
"nonce": "...",
"ciphertext": "..."
}- room context is authenticated as associated data, which prevents replaying a submission into a different room
- participant room keys come from the participant URL fragment and are never sent to the backend
Server-side secret handling:
- bootstrap capabilities, session secrets, and submission edit tokens are stored as salted
scrypthashes inserver/security.ts - session secrets are verified server-side and never stored in plaintext
- The organizer creates a room.
- The API creates:
- an organizer capability
- a participant capability
- a participant decryption key
- The organizer receives:
- an organizer link with
?cap=... - a participant link with
?cap=...#key=...
- an organizer link with
- The browser reads
capfrom the URL query. - The browser reads
keyfrom the participant URL fragment when applicable. - The browser calls
POST /api/session/exchange. - The API returns a short-lived scoped session token.
- The browser immediately removes the capability from the visible URL with
history.replaceState.
- The browser encrypts the submission locally.
- The browser sends the envelope to the API with a participant bearer session.
- The API stores only the encrypted envelope.
- The API returns a rotated edit token.
- The participant connects Google Calendar from the browser.
- The browser fetches busy events directly from Google APIs.
- The browser converts those events into the room availability grid locally.
- The Google access token is kept in memory only and revoked immediately after import.
- The organizer page exchanges its capability for an organizer session.
- The browser calls
DELETE /api/rooms/:roomIdwith bearer auth. - The API deletes the room and cascading submissions.
Browser storage:
- organizer links are stored in
localStorageonly when the user explicitly opts in - active organizer and participant sessions are stored in
sessionStorage - participant submission metadata such as
submissionIdandeditTokenare stored insessionStorage - participant decryption keys are kept in memory after bootstrap and are not persisted by default
- Google OAuth access tokens are kept in memory only and are never persisted
Database storage:
roomsstores room metadata plus hashed organizer and participant bootstrap capabilitiessubmissionsstores encrypted envelopes and hashed edit tokensauth_sessionsstores hashed session secrets, scope, expiry, and last-used metadata
Cleanup behavior:
- expired rooms are opportunistically deleted by the API
- expired auth sessions are opportunistically deleted by the API
Creates a room.
Request body:
{
"title": "Team sync",
"timezone": "Europe/Amsterdam",
"startDate": "2026-03-24",
"endDate": "2026-03-30"
}Response:
- room metadata
retentionDaysorganizerLinkparticipantLink
Exchanges a bootstrap capability for a short-lived session.
Request body:
{
"roomId": "room_123",
"capabilityType": "participant",
"capability": "..."
}Response:
{
"roomId": "room_123",
"capabilityType": "participant",
"sessionToken": "...",
"expiresAt": "2026-03-24T23:00:00.000Z"
}Returns room metadata and encrypted submissions.
Headers:
Authorization: Bearer <participant-session>Creates or updates a participant submission.
Headers:
Authorization: Bearer <participant-session>Request body:
{
"submissionId": "optional-existing-id",
"editToken": "optional-existing-edit-token",
"envelope": {
"version": 2,
"algorithm": "xchacha20poly1305-ietf",
"nonce": "...",
"ciphertext": "..."
}
}Response:
- stored submission metadata
submissionId- rotated
editToken
Deletes a room.
Headers:
Authorization: Bearer <organizer-session>Server-side controls in server/index.ts:
- strict request validation with
zod - exact-origin CORS checks
- request body size limits
- request timeout handling
- per-route rate limiting
helmetsecurity headers- no-referrer policy
- structured security logging without raw secret values
Frontend controls:
- CSP in
index.html - no third-party analytics or trackers
- capability URL scrubbing before normal page use
- participant decryption and overlap calculation happen in the browser
- Google OAuth access tokens are held in memory, used for direct browser-to-Google fetching, and revoked after import
Transport controls:
- production
APP_ORIGINmust be HTTPS - non-local PostgreSQL connections require certificate validation
Frontend:
VITE_GOOGLE_CLIENT_ID=your-google-client-idBackend:
DATABASE_URL=postgres://...
PORT=8787
APP_ORIGIN=https://app.example.com
ALLOWED_ORIGINS=https://app.example.com
ROOM_TTL_DAYS=30
SESSION_TTL_HOURS=12Google Cloud:
- add your app origins to Authorized JavaScript origins
- rotate any previously exposed client secret
- the app only uses the frontend client id
Install dependencies:
npm installRun the client and server:
npm run devBuild production assets:
npm run buildBuild the static frontend for GitHub Pages:
VITE_API_BASE_URL=https://your-railway-app.up.railway.app VITE_PUBLIC_BASE_PATH=/privacy-meetings/ npm run build:pagesRun the production server:
npm startRun tests:
npm testDefault local addresses:
- frontend:
http://127.0.0.1:5173 - API:
http://127.0.0.1:8787
For a single Railway service, deploy the built React app and API together:
- build command:
npm run build - start command:
npm start - set
APP_ORIGINto your public Railway HTTPS URL - set
ALLOWED_ORIGINSto the same public Railway HTTPS URL
With that setup, Express serves the built dist frontend and handles /api/* on the same origin, which avoids 405 Method Not Allowed responses from a static host handling POST /api/....
To host the static app on GitHub Pages and keep the API on Railway:
- deploy the backend to Railway as usual
- set Railway
APP_ORIGINto your GitHub Pages app URL, for examplehttps://USERNAME.github.io/privacy-meetings - set Railway
ALLOWED_ORIGINSto the same GitHub Pages URL - in GitHub, add repository variable
VITE_API_BASE_URLwith your Railway API origin, for examplehttps://your-railway-app.up.railway.app - if you use Google Calendar import, add repository variable
VITE_GOOGLE_CLIENT_ID - enable GitHub Pages with source
GitHub Actions
The workflow in .github/workflows/deploy-pages.yml builds the client with the repository subpath as the Vite base and publishes dist to Pages. It also writes a 404.html copy of index.html so direct visits to /rooms/:roomId and /organize/:roomId keep working on a static host.
Google OAuth verification requires a privacy policy and terms of service on the app domain. Both are rendered by the app itself:
/privacy/insrc/pages/legal.tsx, including the Google API Services User Data Policy Limited Use disclosure for thecalendar.readonlyscope/terms/in the same file- both are linked from the site footer in
src/components/SiteChrome.tsx, together with the operator site notice
Keep them in sync with the actual behaviour of the service, especially the room retention window (ROOM_TTL_DAYS, 30 days by default) and the session lifetime (SESSION_TTL_HOURS, 12 hours by default).
src/App.tsx: bootstrap flow, URL scrubbing, client storage, organizer/participant UIsrc/pages/legal.tsx: privacy policy and terms of service pagessrc/components/SiteChrome.tsx: shared header and footer, including the legal linkssrc/lib/crypto.ts: encryption envelope logicsrc/lib/api.ts: API clientserver/index.ts: API routes and security middlewareserver/security.ts: secret generation, hashing, bearer parsing, security loggingserver/db.ts: schema and persistence
- session expiry is intentional; users may need to re-open the original organizer or participant link
- the participant fragment key is still the highest-value secret on the client side
- if you later want persistence of the participant decryption key, treat that as a separate product and security decision
- the current build lazy-loads
libsodium-wrappers; this keeps the main bundle smaller, but the crypto chunk is still large
The current database model is envelope-only. There is no legacy ciphertext migration path in the active code.