Calendar subscriptions — a live .ics feed of each user's items
prompts/pkg/llms/calendar.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
When an app has per-user dated items — favorited events, RSVPs, picks,
bookings, shifts — offer a calendar subscription: a webcal:// link that
puts the user's items on their phone calendar and keeps them updated as they
add more. This is a backend.js feature (see the backend docs for the general
handler contract); this page is the recipe that makes subscriptions actually
work on real devices.
The architecture (each piece is load-bearing)
Calendar clients (iPhone, Google) refresh subscriptions with anonymous
GETs — no login, no headers you control. An anonymous fetch handler cannot
read the app's databases (anonymous ctx.db.query is denied, and the fetch
lane can't query access-fn-bound databases at all). So:
- A
scheduledtick aggregates; thefetchlane serves.scheduledruns as the owner in admin mode and CAN read everything. Every minute it buildshandle → itemsinto a module-level variable. All handlers share one isolate, so the anonymous GET reads that cache. Useinterval: "1m"— every deploy replaces the isolate (empty cache), and the window must stay short. - The URL carries a per-user RANDOM TOKEN — a capability, not a handle.
Subscription URLs are unauthenticated and long-lived; a handle-keyed URL
(
?u=<handle>) is guessable, which silently makes every user's items world-readable. Instead, MINT the token LOCALLY: the first time the user opens the calendar surface with subscribable content, generate acrypto.getRandomValuestoken client-side and put acaltokendoc{ type: "caltoken", userId, token }, kept PRIVATE by the access function (owner-only channel, like notes — the token IS the secret; the scheduled lane reads it regardless). Local minting makes the URL available instantly (the optimistic write reaches the live query before the click finishes), and it stays opt-in: users who never open that surface get no token and no ics aggregate — the scheduled tick must skip aggregating any user without a token. The URL is?t=<token>&n=<handle>(nis a validated DISPLAY label only — iOS captures the calendar name at subscribe time, often before the tick has learned a fresh token; the token alone gates data): unguessable, shareable on purpose (handing out the link is the sharing feature), and revocable — delete the token doc and the feed drains on the next refresh. Because the token maps to the user (not to item ids), the feed is still LIVE: new items reach every subscriber automatically. Never let private types (notes, drafts) enter the aggregation. - Never answer a subscription GET with an error or emptiness you can
avoid. iOS validates a new subscription by fetching the URL at add time
and shows "Validation failed" on ANY failure — including 503. A cold cache
(isolate just booted, tick hasn't run) must return a valid calendar
(empty, or a hard-coded real anchor event like the festival's "Gates
Open") with
cache-control: no-store. Reserve error statuses (503+Retry-After: 300) for an upstream-join failure where ANY item would have no fallback data — an empty or partial 200 there would wipe or shrink the subscriber's previously-synced events, while an error makes clients keep them and retry. - Validate per item when serving, all-or-nothing never. One malformed legacy doc must drop out of the feed, not 400 it.
- The URL is the subscription's identity — freeze it at first ship.
Subscribers hold the exact URL; changing the path or parameter shape later
strands every existing subscription. Renames go in
X-WR-CALNAMEonly — and even those reach only NEW subscribers, because clients capture the calendar name at subscribe time (same reason then=label exists). Choose the path and display name like you can never change them. - If items mirror an external events API, re-join it on each refresh so
times stay current and cancellations drop off. Call it as
globalThis.fetch(...)— insidebackend.js, barefetchis your own exported handler. Treat a 200 whose body isn't the expected shape as a FAILURE (some APIs return200 {"error": …}), and fall back to stored item data rather than erroring when you have it.
ICS format rules (calendar clients are strict)
- CRLF (
\r\n) line endings; final line too. - Fold lines at 75 octets (bytes, not characters — never split a multibyte character); continuation lines start with one space that counts toward their own 75.
- Escape TEXT values (SUMMARY/LOCATION/DESCRIPTION/X-WR-CALNAME):
backslash first, then
;,,, and newlines →\n. - URL is URI-valued, not TEXT — emit it verbatim (escaping commas corrupts links), which means you must reject URLs containing whitespace or control characters (an embedded CRLF would inject calendar lines).
- DTEND must be strictly after DTSTART. Reject zero-duration items; treat same-day end-before-start as an overnight item and add 24h; default a missing end to start + 2h.
- All times in UTC (
20260731T200000Z), converted from the app's local timezone with anIntl.DateTimeFormatoffset probe (handles DST — never hardcode an offset). - Stable UIDs keyed by item identity (
item-<id>@<app>.vibes.diy) so a refresh updates events instead of duplicating them. - Include
X-WR-CALNAME:@<handle> — <App Name>and refresh hints (REFRESH-INTERVAL;VALUE=DURATION:PT6H+X-PUBLISHED-TTL:PT6H).
The UI
The token is auto-minted when the calendar surface opens, so the Subscribe button is simply there once the user has subscribable content.
⚠️ Read the token from its OWN live query on type: "caltoken" (as below). A
caltoken doc never appears in your items/favorites query — looking for it
there means the Subscribe link never renders, even though the mint effect and
the feed itself work.
jsx// access.js needs a `caltoken` branch routing the doc to the owner's PRIVATE
// channel (like notes) — the token is the secret.
const { docs: calTokens } = useLiveQuery(byTypeUser, { key: ["caltoken", me.userHandle] });
const calToken = calTokens[0]?.token || null;
useEffect(() => {
// Local minting on first visit to this view with content: the URL is ready
// instantly (optimistic write), and users who never come here never get a
// token. Until the backend tick learns the token (≤1m), the endpoint serves
// a valid placeholder calendar, so even an immediate subscribe can't fail.
if (!signedIn || myItems.length === 0 || calToken) return;
const b = new Uint8Array(16);
crypto.getRandomValues(b);
let bin = "";
for (const x of b) bin += String.fromCharCode(x);
const token = btoa(bin).replace(/[+]/g, "-").replace(/[/]/g, "_").replace(/=+$/, "");
database.put({ _id: `caltoken-${me.userHandle}`, type: "caltoken", userId: me.userHandle, token, createdAt: Date.now() }).catch(() => {});
}, [signedIn, myItems.length, calToken]);
const icsPath = signedIn && calToken
? `/_api/faves.ics?t=${encodeURIComponent(calToken)}&n=${encodeURIComponent(me.userHandle)}`
: null;
{icsPath && (
<a href={`webcal://${window.location.host}${icsPath}`} target="_blank" rel="noopener noreferrer"
title="Subscribe in your phone's calendar — it follows your picks live. iOS may warn about an insecure connection; tap Continue. Share the link and friends can follow your picks.">
📆 Subscribe on iPhone
</a>
)}
// Plus a copy button that copies `https://${window.location.host}${icsPath}` —
// that form pastes into Google Calendar (From URL) and into iOS Settings →
// Calendar → Add Subscribed Calendar (which skips the warning entirely).
Use webcal://, never webcals:// — iOS Safari rejects the secure-scheme
variant as an invalid address. The one-time "Insecure Connection" prompt is
expected; Continue works (fetches ride the https redirect). Tell the user in
the button tooltip.
Complete backend.js
favorite docs look like { type: "favorite", userId, itemId, event: { date: "2026-07-31", time: "18:00:00", endtime: "", title, venue, url } } — adapt
the doc shape, db name, and timezone to the app.
jsexport const config = { scheduled: { interval: "1m" } };
const TZ = "America/Los_Angeles"; // the app's local timezone
const fmt = new Intl.DateTimeFormat("en-US", { timeZone: TZ, hourCycle: "h23",
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
const tzOffsetMin = (d) => {
const p = Object.fromEntries(fmt.formatToParts(d).map((x) => [x.type, x.value]));
return (Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - d.getTime()) / 60000;
};
const parseLocal = (s) => {
if (typeof s !== "string" || !s) return null;
if (/([+-]\d\d:\d\d|Z)$/.test(s)) { const d = new Date(s); return isNaN(d) ? null : d; }
const guess = new Date(s.replace(" ", "T") + "Z");
if (isNaN(guess)) return null;
return new Date(guess.getTime() - tzOffsetMin(guess) * 60000);
};
const utc = (ms) => new Date(ms).toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z";
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
const octets = (ch) => { const c = ch.codePointAt(0); return c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; };
const fold = (line) => {
const parts = []; let cur = "", bytes = 0, budget = 75;
for (const ch of line) { const n = octets(ch);
if (bytes + n > budget) { parts.push(cur); cur = ""; bytes = 0; budget = 74; }
cur += ch; bytes += n; }
parts.push(cur); return parts.join("\r\n ");
};
// event snapshot → validated item, or null (bad rows drop out per item).
const toItem = (itemId, ev) => {
if (!ev || !ev.date || !ev.time || !ev.title) return null;
const start = parseLocal(`${ev.date}T${ev.time}`);
if (!start) return null;
let end = ev.endtime && ev.endtime !== ev.time ? parseLocal(`${ev.date}T${ev.endtime}`) : null;
let endMs = end ? end.getTime() : start.getTime() + 2 * 3600_000; // default 2h
if (endMs < start.getTime()) endMs += 24 * 3600_000; // overnight (end before start, same-day form)
if (endMs <= start.getTime()) return null;
const url = typeof ev.url === "string" && /^https?:\/\/[^\s\x00-\x1f\x7f]+$/i.test(ev.url) ? ev.url : null;
return { id: itemId, title: ev.title, start: utc(start.getTime()), end: utc(endMs), venue: ev.venue, url };
};
const buildIcs = (handle, items) => {
const stamp = utc(Date.now());
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//vibes.diy//app//EN", "CALSCALE:GREGORIAN",
"METHOD:PUBLISH", `X-WR-CALNAME:${esc(`@${handle} — My Picks`)}`, `X-WR-TIMEZONE:${TZ}`,
"REFRESH-INTERVAL;VALUE=DURATION:PT6H", "X-PUBLISHED-TTL:PT6H"];
for (const it of [...items].sort((a, b) => (a.start < b.start ? -1 : 1))) {
lines.push("BEGIN:VEVENT", `UID:item-${it.id.replace(/[^A-Za-z0-9._-]/g, "-")}@app.vibes.diy`,
`DTSTAMP:${stamp}`, `DTSTART:${it.start}`, `DTEND:${it.end}`, `SUMMARY:${esc(it.title)}`);
if (it.venue) lines.push(`LOCATION:${esc(it.venue)}`);
if (it.url) lines.push(`URL:${it.url}`); // URI-valued: verbatim, pre-validated
lines.push("END:VEVENT");
}
lines.push("END:VCALENDAR");
return lines.map(fold).join("\r\n") + "\r\n";
};
let cache = null; // { users: handle → [{itemId, event}], tokens: token → handle }
export async function scheduled(event, ctx) {
const docs = await ctx.db.query({ db: "items" }); // admin-mode read
const users = new Map();
const tokens = new Map();
for (const d of docs) {
if (d.type === "caltoken" && typeof d.token === "string" && /^[A-Za-z0-9_-]{16,64}$/.test(d.token) && d.userId) {
tokens.set(d.token, String(d.userId).toLowerCase()); // the opt-in capability
continue;
}
if (d.type !== "favorite" || !d.userId || d.itemId == null) continue; // ONLY shared types
const k = String(d.userId).toLowerCase();
if (!users.has(k)) users.set(k, []);
users.get(k).push({ itemId: String(d.itemId), event: d.event });
}
// Opt-in means opt-in: drop aggregates for users without a token.
const optedIn = new Set(tokens.values());
for (const h of [...users.keys()]) if (!optedIn.has(h)) users.delete(h);
cache = { users, tokens };
}
export async function fetch(request, ctx) {
const url = new URL(request.url);
if (url.pathname !== "/faves.ics") return new Response("not found", { status: 404 });
if (request.method !== "GET" && request.method !== "HEAD")
return new Response("method not allowed", { status: 405, headers: { allow: "GET" } });
const t = url.searchParams.get("t") ?? "";
if (!/^[A-Za-z0-9_-]{16,64}$/.test(t)) return new Response("pass t=<calendar token>", { status: 400 });
// Display label only (token gates the data): iOS captures the calendar name
// at subscribe time, often before the tick knows a freshly-minted token.
const nRaw = (url.searchParams.get("n") ?? "").toLowerCase();
const displayName = /^[a-z0-9][a-z0-9_-]{0,39}$/.test(nRaw) ? nRaw : null;
const cold = cache === null; // tick hasn't run yet: serve VALID + empty, never 503
// Unknown token → EMPTY valid calendar too: a just-created token can beat
// the next tick (a 404 there fails iOS's add-time validation), and a
// revoked token draining to empty is exactly what revocation should do.
const handle = cold ? undefined : cache.tokens.get(t);
const faves = (handle && cache.users.get(handle)) || [];
const items = faves.map((f) => toItem(f.itemId, f.event)).filter(Boolean);
return new Response(buildIcs(handle ?? displayName ?? "my", items), {
status: 200,
headers: { "content-type": "text/calendar; charset=utf-8",
"cache-control": cold || !handle ? "no-store" : "public, max-age=300" },
});
}
If items should track a live external events API, add the join inside fetch:
fetch the API window with globalThis.fetch, key rows by the same item id,
prefer the live row over the stored snapshot, skip items the API marks
cancelled, and keep the snapshot path as the fallback when the API is down or
the row is past the window.