| … | … | @@ -0,0 +1,240 @@ |
| 1 | # Google Calendar sync/edit on DreamHost — feasibility research |
| 2 | |
| 3 | **Date:** 2026-07-29 |
| 4 | **Status:** Research complete. Blocked on ONE fact: is the target DreamHost **Shared** or **VPS**? |
| 5 | **Question asked:** "can we make a bare library that will sync / edit google calendar?" |
| 6 | **Short answer:** Yes on VPS. On Shared, `bare` is impossible and the only runtime left is **PHP**. |
| 7 | |
| 8 | --- |
| 9 | |
| 10 | ## 1. Prior art (already in this repo) |
| 11 | |
| 12 | Google Calendar work already exists here — this was NOT greenfield: |
| 13 | |
| 14 | | File | Lines | What | |
| 15 | |---|---|---| |
| 16 | | `lib/gcal.js` | 275 | `GoogleCalendarClient` — OAuth2 code exchange, token refresh (5-min buffer), FreeBusy, calendarList, Hyperbee token store + 5-min cache, disconnect | |
| 17 | | `lib/routes/gcal.js` | 211 | connect / callback / calendars / disconnect, CSRF nonce state | |
| 18 | | `test/gcal.test.js` | 617 | 17 tests, **all against an injected mock `httpClient`** | |
| 19 | | `docs/design/v1-google-calendar.md` | 671 | Full spec incl. push channels, edge cases, build plan | |
| 20 | |
| 21 | **Test baseline (measured, `bare test/gcal.test.js`): 15/17 pass.** |
| 22 | The 2 failures are pre-existing HTTP-integration tests |
| 23 | (`integration — slots API excludes Google Calendar busy times`, |
| 24 | `integration — slots API works without gcal configured`). |
| 25 | All 14 unit tests pass. Not investigated — unrelated to this research. |
| 26 | |
| 27 | ### What prior art does NOT cover |
| 28 | - **Read-only.** Scopes are `calendar.freebusy` + `calendar.calendarlist.readonly`. |
| 29 | - **No event CRUD** — no `events.insert/patch/delete`. This is the "edit" half. |
| 30 | - **No incremental sync** — no `events.list` + `syncToken`. This is the "sync" half. |
| 31 | - **Not a library** — takes a Hyperbee `db`, hardcodes `gcal:provider:${id}` keys, |
| 32 | `console.error`s directly, assumes hyper-booking's provider model. |
| 33 | |
| 34 | --- |
| 35 | |
| 36 | ## 2. Verified by measurement (not docs) |
| 37 | |
| 38 | Everything in this section was actually run on 2026-07-29. |
| 39 | |
| 40 | - **`bare-fetch` does TLS to Google.** |
| 41 | `GET https://accounts.google.com/.well-known/openid-configuration` |
| 42 | → **200 in 159 ms** under `bare v1.26.0`. Every existing test is mocked, so this |
| 43 | had never been proven before. |
| 44 | |
| 45 | - **`lib/gcal.js` runs UNMODIFIED under plain node.** |
| 46 | Ran under `node v24.13.0` with global `fetch` injected as `httpClient`: |
| 47 | `mergeBusyBlocks` correct; live token-endpoint call rejected with the real Google |
| 48 | error `"The OAuth client was not found."` |
| 49 | Works because `gcal.js` imports nothing runtime-specific at top level — it only |
| 50 | lazy-imports `bare-fetch` as a *fallback* when no `httpClient` is injected. |
| 51 | **That injection seam is the portability escape hatch.** |
| 52 | |
| 53 | - **`bare` linux-x64 requires `GLIBC_2.34`.** |
| 54 | `objdump -T .../bare-runtime-linux-x64/bin/bare` → max symbol `GLIBC_2.34`. |
| 55 | Dynamically linked against `libstdc++`, `libgcc_s`, `libm`, `libc`. Not static. |
| 56 | → needs Ubuntu 22.04+ / Debian 12+ / RHEL 9+. |
| 57 | |
| 58 | --- |
| 59 | |
| 60 | ## 3. DreamHost capability matrix |
| 61 | |
| 62 | ### Shared |
| 63 | | | | |
| 64 | |---|---| |
| 65 | | Node.js | **Not supported.** VPS/Dedicated only. Third-party docs citing DreamHost's wiki warn that attempting to compile Node **can get the account locked**. | |
| 66 | | Passenger | **Removed from Shared** ("after March 31st"; surrounding blog context = 2024). This was the only route to Python/Ruby. | |
| 67 | | Persistent processes | **Forbidden by policy.** `procwatch` kills on RAM, CPU, or *"running for over a week."* | |
| 68 | | Cron | ≥ 5 min interval, ≤ 5 simultaneous jobs (AUP) | |
| 69 | | PHP | 8.1–8.5, including **command-line PHP** | |
| 70 | | MySQL | 8 (shared server) | |
| 71 | | HTTPS | free Let's Encrypt | |
| 72 | |
| 73 | **⇒ On Shared the only server-side runtime is PHP.** Not a preference — the only |
| 74 | thing left standing. `bare` cannot run (no Node, no compile, glibc question never |
| 75 | even arises). |
| 76 | |
| 77 | ### VPS / Dedicated |
| 78 | - Ubuntu **22.04 (Jammy)** or **24.04 (Noble)**; 24.04 is the 2026 default. |
| 79 | → glibc **2.35 / 2.39**, both above bare's **2.34** floor. **`bare` runs.** |
| 80 | - Root access. `procwatch` does **not** run on Managed VPS / Dedicated / DreamPress. |
| 81 | - ⚠️ **Do not use DreamHost's Passenger for Node** — it "can't host Node.js apps |
| 82 | newer than version 13." Run `bare`/node directly under systemd or pm2 instead. |
| 83 | - ⚠️ Managed vs Self-Managed VPS may differ on root / binary-exec rights — unconfirmed. |
| 84 | |
| 85 | *Caveat: DreamHost's KB returns 403 to automated fetches, so the above is from |
| 86 | search excerpts and third-party docs, not pages read directly. Confirm in panel.* |
| 87 | |
| 88 | --- |
| 89 | |
| 90 | ## 4. Google OAuth constraints (all verified against Google docs) |
| 91 | |
| 92 | These eliminate most "headless auth" options: |
| 93 | |
| 94 | - **Device flow does NOT support Calendar.** Allowed scopes are exactly: |
| 95 | `email`, `openid`, `profile`, `drive.appdata`, `drive.file`, `youtube`, |
| 96 | `youtube.readonly`. *"The OAuth 2.0 flow for devices is supported only for the |
| 97 | following scopes."* No Calendar scope of any kind. |
| 98 | ⚠️ A `device/code` probe with a bogus `client_id` returns `401 invalid_client` |
| 99 | **before** scope validation — so that probe does NOT prove scope support. |
| 100 | This is how the wrong recommendation was initially made in-session. |
| 101 | |
| 102 | - **OOB / manual copy-paste is fully REMOVED**, not deprecated. |
| 103 | `urn:ietf:wg:oauth:2.0:oob` blocked Oct 3 2022, enforced Feb 1 2023. |
| 104 | |
| 105 | - **Loopback IP is supported and recommended for desktop apps** — |
| 106 | `http://127.0.0.1:PORT` / `http://[::1]:PORT`. Deprecated for **mobile** only. |
| 107 | Requires a *Desktop app* OAuth client type. |
| 108 | |
| 109 | - **PKCE** supported, "recommended" not required. |
| 110 | |
| 111 | ⇒ For Calendar there are exactly **two** viable flows: |
| 112 | **(A)** loopback redirect (desktop client), **(B)** public HTTPS callback (web client). |
| 113 | |
| 114 | --- |
| 115 | |
| 116 | ## 5. Architecture by tier |
| 117 | |
| 118 | ### If Shared → PHP + MySQL + cron |
| 119 | |
| 120 | The callback is an ordinary route — **the app IS the public host, so there is no NAT |
| 121 | and no relay**. PKCE becomes optional (its job was protecting a code across an |
| 122 | untrusted hop; there is no hop). |
| 123 | |
| 124 | ``` |
| 125 | ~/yourdomain.com/ (webroot) |
| 126 | oauth/start.php → build auth URL, redirect to Google |
| 127 | oauth/callback.php → code → tokens → MySQL |
| 128 | webhook.php → optional: Google push notifications |
| 129 | ~/lib/ (OUTSIDE webroot) |
| 130 | gcal.php → refresh, list, insert, patch, delete |
| 131 | db.php |
| 132 | config.php → client_secret, chmod 600 |
| 133 | ~/bin/ |
| 134 | sync.php → cron: php8.3 ~/bin/sync.php |
| 135 | ``` |
| 136 | |
| 137 | ```sql |
| 138 | CREATE TABLE gcal_account ( |
| 139 | id INT AUTO_INCREMENT PRIMARY KEY, |
| 140 | user_id VARCHAR(64) NOT NULL, |
| 141 | refresh_token VARBINARY(512) NOT NULL, -- encrypt at rest |
| 142 | access_token VARBINARY(512), |
| 143 | token_expiry BIGINT, |
| 144 | sync_token VARCHAR(255), -- incremental cursor |
| 145 | channel_id VARCHAR(64), -- if using push |
| 146 | channel_expiry BIGINT, |
| 147 | UNIQUE KEY (user_id) |
| 148 | ); |
| 149 | ``` |
| 150 | |
| 151 | Why PHP genuinely fits: the Calendar API is plain REST/JSON over HTTPS, and OAuth + |
| 152 | REST are **request-scoped** — exactly shared hosting's execution model. PHP core has |
| 153 | `curl`, `json`, `hash`, `openssl`, `random_bytes`. No Composer, no Google SDK. |
| 154 | |
| 155 | **Two correctness requirements:** |
| 156 | 1. **Handle `410 GONE`.** Sync tokens get invalidated server-side (expiry, ACL |
| 157 | changes); response carries `"reason": "fullSyncRequired"`. Must discard the token |
| 158 | and re-run a **full paginated sync** to earn a fresh one. Will happen in production. |
| 159 | 2. **Push notifications still need cron.** Google: *"there's no automatic way to renew |
| 160 | a notification channel — when a channel is close to its expiration, you must |
| 161 | replace it"* via `watch`. Let's Encrypt satisfies the valid-cert requirement, so |
| 162 | push works, but it **adds** a renewal job rather than removing the cron. |
| 163 | → **Start cron-only (`syncToken`); add push later purely as latency optimization.** |
| 164 | |
| 165 | ### If VPS → standalone runtime-neutral `bare-gcal` |
| 166 | |
| 167 | The originally chosen design is back on the table: |
| 168 | |
| 169 | ``` |
| 170 | bare-gcal/ |
| 171 | lib/auth.js loopback flow + web-redirect flow + refresh (NOT device flow) |
| 172 | lib/events.js list / insert / patch / delete |
| 173 | lib/sync.js syncToken incremental; syncOnce() + optional loop |
| 174 | lib/freebusy.js lifted from hyper-booking/lib/gcal.js as-is |
| 175 | lib/store.js TokenStore interface + HyperbeeTokenStore |
| 176 | ``` |
| 177 | Core carries **zero `bare-*` imports**; `fetch` injected or global (proven in §2). |
| 178 | `hyper-booking/lib/gcal.js` becomes a thin adapter. |
| 179 | |
| 180 | --- |
| 181 | |
| 182 | ## 6. ⚠️ The tradeoff if Shared is chosen |
| 183 | |
| 184 | Dropping bare/node on Shared **removes the entire Hypercore stack**. Hyperswarm needs |
| 185 | a persistent process and UDP; Hyperbee and autobase need a live process holding |
| 186 | storage open. None survive `procwatch`. |
| 187 | |
| 188 | The result is a conventional LAMP app with **no P2P properties whatsoever**. That may |
| 189 | be the right call for "sync a client's calendar on hosting they already pay for" — |
| 190 | but it is a different kind of app from everything else in this KB, and **it cannot be |
| 191 | walked back later on Shared at any price.** P2P requires VPS. |
| 192 | |
| 193 | --- |
| 194 | |
| 195 | ## 7. What transfers regardless of tier |
| 196 | |
| 197 | `lib/gcal.js` is mostly runtime-independent algorithm; the PHP port is mechanical: |
| 198 | - `mergeBusyBlocks()` (lines 245–275) — interval sort + merge, pure logic |
| 199 | - token refresh with the **5-minute expiry buffer** (line 117) — worth keeping |
| 200 | - cache-TTL pattern; graceful-degradation-on-API-failure (never blocks slot calc) |
| 201 | - endpoint URLs and OAuth parameter shapes, already correct |
| 202 | |
| 203 | ~100 lines of thought-through, tested logic. |
| 204 | |
| 205 | --- |
| 206 | |
| 207 | ## 8. Open questions / next actions |
| 208 | |
| 209 | 1. **☐ BLOCKING: Shared or VPS?** Decides language, architecture, and whether P2P is |
| 210 | possible at all. If SSH access exists, this settles the environment: |
| 211 | ```bash |
| 212 | ldd --version | head -1; uname -m; hostname; node -v 2>/dev/null || echo "no node" |
| 213 | ``` |
| 214 | 2. ☐ Confirm the Passenger-removal year in the DreamHost panel (KB 403s automated fetch). |
| 215 | 3. ☐ Confirm Managed vs Self-Managed VPS root / binary-exec rights (if VPS). |
| 216 | 4. ☐ Register OAuth client — **type depends on flow**: *Desktop app* for loopback, |
| 217 | *Web application* for an HTTPS callback. Different credentials. |
| 218 | 5. ☐ Scope upgrade to `calendar.events` — current scopes cannot write. |
| 219 | 6. ☐ First build step either way: a **live OAuth round-trip against real Google** |
| 220 | (everything to date is mocked; that is the piece most likely to surprise). |
| 221 | 7. ☐ Unrelated pre-existing: 2 failing integration tests in `test/gcal.test.js`. |
| 222 | |
| 223 | --- |
| 224 | |
| 225 | ## Sources |
| 226 | |
| 227 | - [Node.js overview – DreamHost KB](https://help.dreamhost.com/hc/en-us/articles/217185397-Node-js-overview) |
| 228 | - [Persistent processes – DreamHost KB](https://help.dreamhost.com/hc/en-us/articles/214869648-Persistent-processes) |
| 229 | - [Why did procwatch kill processes – DreamHost KB](https://help.dreamhost.com/hc/en-us/articles/216540488-Why-did-procwatch-kill-processes-on-my-Shared-server-) |
| 230 | - [Passenger overview – DreamHost KB](https://help.dreamhost.com/hc/en-us/articles/215769578-Passenger-overview) |
| 231 | - [Migrating a Django site off DreamHost Shared Passenger](https://orangegnome.com/posts/2880/migrating-a-django-website-from-dreamhost-shared-server-with-passenger-to-vps-with-gunicorn) |
| 232 | - [PHP versions at DreamHost](https://help.dreamhost.com/hc/en-us/articles/215082337-What-versions-of-PHP-are-available-at-DreamHost) |
| 233 | - [MySQL overview – DreamHost KB](https://help.dreamhost.com/hc/en-us/articles/215099117-MySQL-overview) |
| 234 | - [Create a cron job – DreamHost KB](https://help.dreamhost.com/hc/en-us/articles/215088668-Create-a-cron-job) |
| 235 | - [Ubuntu 22.04 vs 24.04 for a VPS – DreamHost](https://www.dreamhost.com/blog/ubuntu-22-04-lts-24-04-lts-vps/) |
| 236 | - [OAuth 2.0 for TV and Limited-Input Devices](https://developers.google.com/identity/protocols/oauth2/limited-input-device) |
| 237 | - [OAuth 2.0 for Mobile & Desktop Apps](https://developers.google.com/identity/protocols/oauth2/native-app) |
| 238 | - [OOB Flow Migration Guide](https://developers.google.com/identity/protocols/oauth2/resources/oob-migration) |
| 239 | - [Calendar API: Push Notifications](https://developers.google.com/workspace/calendar/api/guides/push) |
| 240 | - [Calendar API: Synchronize resources efficiently](https://developers.google.com/workspace/calendar/api/guides/sync) |