@volter/twin-calcom 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +90 -0
- package/client/calcom-mirror.css +29 -0
- package/client/calcom-mirror.tsx +125 -0
- package/dist/client/calcom-mirror.bundle.js +321 -0
- package/dist/client/calcom-mirror.css +29 -0
- package/dist/client/calcom-mirror.d.ts +13 -0
- package/dist/client/calcom-mirror.js +61 -0
- package/dist/client/calcom-mirror.tsx +125 -0
- package/dist/src/calcom-budget.d.ts +52 -0
- package/dist/src/calcom-budget.js +145 -0
- package/dist/src/calcom-capabilities.d.ts +3 -0
- package/dist/src/calcom-capabilities.js +521 -0
- package/dist/src/calcom-conformance.d.ts +15 -0
- package/dist/src/calcom-conformance.js +86 -0
- package/dist/src/calcom-connector.d.ts +90 -0
- package/dist/src/calcom-connector.js +229 -0
- package/dist/src/calcom-mirror-ui.d.ts +23 -0
- package/dist/src/calcom-mirror-ui.js +100 -0
- package/dist/src/calcom-perform-harness.d.ts +5 -0
- package/dist/src/calcom-perform-harness.js +29 -0
- package/dist/src/calcom-server.d.ts +14 -0
- package/dist/src/calcom-server.js +31 -0
- package/dist/src/calcom-twin.d.ts +16 -0
- package/dist/src/calcom-twin.js +718 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +69 -0
- package/package.json +72 -0
- package/src/calcom-budget.ts +171 -0
- package/src/calcom-capabilities.ts +529 -0
- package/src/calcom-conformance.ts +92 -0
- package/src/calcom-connector.ts +258 -0
- package/src/calcom-mirror-ui.ts +110 -0
- package/src/calcom-perform-harness.ts +24 -0
- package/src/calcom-server.ts +39 -0
- package/src/calcom-twin.ts +675 -0
- package/src/cli.ts +29 -0
- package/src/index.ts +101 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0f1115;
|
|
3
|
+
--panel: #171a21;
|
|
4
|
+
--line: #262b35;
|
|
5
|
+
--text: #e6e9ef;
|
|
6
|
+
--muted: #9aa4b2;
|
|
7
|
+
--accent: #292929;
|
|
8
|
+
--ok: #2fae66;
|
|
9
|
+
--warn: #d9a334;
|
|
10
|
+
--bad: #d24b4b;
|
|
11
|
+
}
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
body { margin: 0; font-family: -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
|
14
|
+
.app { display: grid; grid-template-columns: 200px 1fr; grid-template-rows: 52px 1fr; min-height: 100vh; }
|
|
15
|
+
.topbar { grid-column: 1 / 3; display: flex; align-items: center; padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
16
|
+
.brand { font-weight: 700; }
|
|
17
|
+
.sidebar { border-right: 1px solid var(--line); padding: 12px 8px; background: var(--panel); }
|
|
18
|
+
.nav-item { display: block; width: 100%; text-align: left; background: none; border: none; color: var(--muted); padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
|
19
|
+
.nav-item.active, .nav-item:hover { background: var(--accent); color: var(--text); }
|
|
20
|
+
.content { padding: 20px; overflow: auto; }
|
|
21
|
+
table { width: 100%; border-collapse: collapse; }
|
|
22
|
+
th { text-align: left; color: var(--muted); font-size: 12px; text-transform: uppercase; padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
|
23
|
+
td { padding: 10px; border-bottom: 1px solid var(--line); font-size: 14px; }
|
|
24
|
+
.cell-title { font-weight: 600; }
|
|
25
|
+
.pill { padding: 2px 8px; border-radius: 999px; font-size: 12px; }
|
|
26
|
+
.pill-ok { background: rgba(47,174,102,0.18); color: var(--ok); }
|
|
27
|
+
.pill-warn { background: rgba(217,163,52,0.18); color: var(--warn); }
|
|
28
|
+
.pill-bad { background: rgba(210,75,75,0.18); color: var(--bad); }
|
|
29
|
+
.empty { color: var(--muted); padding: 24px; }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type CalcomRow } from '../src/calcom-mirror-ui.js';
|
|
2
|
+
/** The bookings table — one `list-row` landmark per booking, carrying its uid. */
|
|
3
|
+
export declare function BookingsList({ rows }: {
|
|
4
|
+
rows: CalcomRow[];
|
|
5
|
+
}): import("react").JSX.Element;
|
|
6
|
+
/** Booking detail — one `list-row` per attendee/host, plus a reschedule-history line. */
|
|
7
|
+
export declare function BookingDetail({ rows }: {
|
|
8
|
+
rows: CalcomRow[];
|
|
9
|
+
}): import("react").JSX.Element;
|
|
10
|
+
/** Event types table — one `list-row` per event type. */
|
|
11
|
+
export declare function EventTypesList({ rows }: {
|
|
12
|
+
rows: CalcomRow[];
|
|
13
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Cal.com mirror — React/TSX dashboard client. Renders the twin's bookings + event types
|
|
3
|
+
// by fetching the twin's OWN v2 REST API on the same origin (/v2/bookings, /v2/event-types).
|
|
4
|
+
// API↔UI parity: the screen shows real twin state, not a hardcoded shell.
|
|
5
|
+
import { createRoot } from 'react-dom/client';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import { attendeeLabel, bookingTone, formatWindow } from "../src/calcom-mirror-ui.js";
|
|
8
|
+
// WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
|
|
9
|
+
// before); served under a path prefix with a <base> tag every read and write resolves inside that
|
|
10
|
+
// prefix instead of escaping it.
|
|
11
|
+
const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
12
|
+
// Map a tone to a STATIC literal class name (a template-literal class is erased by the
|
|
13
|
+
// minifier, so `pill-${tone}` would vanish from the bundle — assert these instead).
|
|
14
|
+
const PILL_CLASS = { ok: 'pill pill-ok', warn: 'pill pill-warn', bad: 'pill pill-bad', '': 'pill' };
|
|
15
|
+
const SECTIONS = [
|
|
16
|
+
{ key: 'bookings', label: 'Bookings', path: '/v2/bookings' },
|
|
17
|
+
{ key: 'event-types', label: 'Event Types', path: '/v2/event-types' },
|
|
18
|
+
];
|
|
19
|
+
// Unwrap the v2 envelope ({ status, data }) to its data array.
|
|
20
|
+
async function fetchData(path) {
|
|
21
|
+
const res = await fetch(`${WIRE_BASE}${path}`);
|
|
22
|
+
const body = (await res.json());
|
|
23
|
+
return Array.isArray(body.data) ? body.data : [];
|
|
24
|
+
}
|
|
25
|
+
/** The bookings table — one `list-row` landmark per booking, carrying its uid. */
|
|
26
|
+
export function BookingsList({ rows }) {
|
|
27
|
+
if (rows.length === 0)
|
|
28
|
+
return _jsx("div", { className: "empty", children: "No bookings yet." });
|
|
29
|
+
return (_jsxs("table", { className: "bookings-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Title" }), _jsx("th", { children: "When" }), _jsx("th", { children: "Attendee" }), _jsx("th", { children: "Status" })] }) }), _jsx("tbody", { children: rows.map((b) => (_jsxs("tr", { className: "list-row", "data-uid": b.uid, children: [_jsx("td", { className: "cell-title", children: b.title }), _jsx("td", { className: "cell-when", children: formatWindow(b.start, b.end) }), _jsx("td", { className: "cell-attendee", children: attendeeLabel(b.attendees) }), _jsx("td", { children: _jsx("span", { className: PILL_CLASS[bookingTone(b.status)], children: b.status }) })] }, b.uid))) })] }));
|
|
30
|
+
}
|
|
31
|
+
/** Booking detail — one `list-row` per attendee/host, plus a reschedule-history line. */
|
|
32
|
+
export function BookingDetail({ rows }) {
|
|
33
|
+
const b = rows[0];
|
|
34
|
+
if (!b)
|
|
35
|
+
return _jsx("div", { className: "empty", children: "No booking." });
|
|
36
|
+
const attendees = Array.isArray(b.attendees) ? b.attendees : [];
|
|
37
|
+
const hosts = Array.isArray(b.hosts) ? b.hosts : [];
|
|
38
|
+
const people = [
|
|
39
|
+
...hosts.map((h) => ({ role: 'host', name: h.name, email: h.email ?? '', id: `host-${h.id ?? h.name}` })),
|
|
40
|
+
...attendees.map((a) => ({ role: 'attendee', name: a.name, email: a.email, id: `att-${a.email}` })),
|
|
41
|
+
];
|
|
42
|
+
return (_jsxs("section", { className: "booking-detail", "data-uid": b.uid, children: [_jsx("h2", { className: "detail-title", children: b.title }), _jsx("div", { className: "detail-when", children: formatWindow(b.start, b.end) }), _jsx("div", { children: _jsx("span", { className: PILL_CLASS[bookingTone(b.status)], children: b.status }) }), _jsx("table", { className: "people-table", children: _jsx("tbody", { children: people.map((p) => (_jsxs("tr", { className: "list-row", "data-pid": p.id, children: [_jsx("td", { className: "cell-role", children: p.role }), _jsx("td", { className: "cell-name", children: String(p.name ?? '') }), _jsx("td", { className: "cell-email", children: String(p.email ?? '') })] }, p.id))) }) }), b.fromReschedule ? _jsxs("div", { className: "reschedule-history", children: ["rescheduled from ", String(b.fromReschedule)] }) : null] }));
|
|
43
|
+
}
|
|
44
|
+
/** Event types table — one `list-row` per event type. */
|
|
45
|
+
export function EventTypesList({ rows }) {
|
|
46
|
+
if (rows.length === 0)
|
|
47
|
+
return _jsx("div", { className: "empty", children: "No event types yet." });
|
|
48
|
+
return (_jsxs("table", { className: "evt-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Title" }), _jsx("th", { children: "Slug" }), _jsx("th", { children: "Length" })] }) }), _jsx("tbody", { children: rows.map((e) => (_jsxs("tr", { className: "list-row", "data-id": e.id, children: [_jsx("td", { className: "cell-title", children: e.title }), _jsx("td", { children: e.slug }), _jsxs("td", { children: [e.lengthInMinutes, " min"] })] }, e.id))) })] }));
|
|
49
|
+
}
|
|
50
|
+
function App() {
|
|
51
|
+
const [section, setSection] = useState('bookings');
|
|
52
|
+
const [rows, setRows] = useState([]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const sec = SECTIONS.find((s) => s.key === section);
|
|
55
|
+
fetchData(sec.path).then(setRows).catch(() => setRows([]));
|
|
56
|
+
}, [section]);
|
|
57
|
+
return (_jsxs("div", { className: "app", children: [_jsx("header", { className: "topbar", children: _jsx("span", { className: "brand", children: "Cal.com twin" }) }), _jsx("nav", { className: "sidebar", children: SECTIONS.map((s) => (_jsx("button", { className: `nav-item${s.key === section ? ' active' : ''}`, onClick: () => setSection(s.key), children: s.label }, s.key))) }), _jsx("main", { className: "content", children: section === 'bookings' ? _jsx(BookingsList, { rows: rows }) : _jsx(EventTypesList, { rows: rows }) })] }));
|
|
58
|
+
}
|
|
59
|
+
const el = typeof document !== 'undefined' ? document.getElementById('root') : null;
|
|
60
|
+
if (el)
|
|
61
|
+
createRoot(el).render(_jsx(App, {}));
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Cal.com mirror — React/TSX dashboard client. Renders the twin's bookings + event types
|
|
2
|
+
// by fetching the twin's OWN v2 REST API on the same origin (/v2/bookings, /v2/event-types).
|
|
3
|
+
// API↔UI parity: the screen shows real twin state, not a hardcoded shell.
|
|
4
|
+
import { createRoot } from 'react-dom/client';
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
import { attendeeLabel, bookingTone, formatWindow, type CalcomRow, type PillTone } from '../src/calcom-mirror-ui.ts';
|
|
7
|
+
|
|
8
|
+
// WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
|
|
9
|
+
// before); served under a path prefix with a <base> tag every read and write resolves inside that
|
|
10
|
+
// prefix instead of escaping it.
|
|
11
|
+
const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
12
|
+
|
|
13
|
+
// Map a tone to a STATIC literal class name (a template-literal class is erased by the
|
|
14
|
+
// minifier, so `pill-${tone}` would vanish from the bundle — assert these instead).
|
|
15
|
+
const PILL_CLASS: Record<PillTone, string> = { ok: 'pill pill-ok', warn: 'pill pill-warn', bad: 'pill pill-bad', '': 'pill' };
|
|
16
|
+
|
|
17
|
+
const SECTIONS = [
|
|
18
|
+
{ key: 'bookings', label: 'Bookings', path: '/v2/bookings' },
|
|
19
|
+
{ key: 'event-types', label: 'Event Types', path: '/v2/event-types' },
|
|
20
|
+
] as const;
|
|
21
|
+
type SectionKey = (typeof SECTIONS)[number]['key'];
|
|
22
|
+
|
|
23
|
+
// Unwrap the v2 envelope ({ status, data }) to its data array.
|
|
24
|
+
async function fetchData(path: string): Promise<CalcomRow[]> {
|
|
25
|
+
const res = await fetch(`${WIRE_BASE}${path}`);
|
|
26
|
+
const body = (await res.json()) as { data?: unknown };
|
|
27
|
+
return Array.isArray(body.data) ? (body.data as CalcomRow[]) : [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The bookings table — one `list-row` landmark per booking, carrying its uid. */
|
|
31
|
+
export function BookingsList({ rows }: { rows: CalcomRow[] }) {
|
|
32
|
+
if (rows.length === 0) return <div className="empty">No bookings yet.</div>;
|
|
33
|
+
return (
|
|
34
|
+
<table className="bookings-table">
|
|
35
|
+
<thead>
|
|
36
|
+
<tr><th>Title</th><th>When</th><th>Attendee</th><th>Status</th></tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
{rows.map((b) => (
|
|
40
|
+
<tr className="list-row" data-uid={b.uid} key={b.uid}>
|
|
41
|
+
<td className="cell-title">{b.title}</td>
|
|
42
|
+
<td className="cell-when">{formatWindow(b.start, b.end)}</td>
|
|
43
|
+
<td className="cell-attendee">{attendeeLabel(b.attendees)}</td>
|
|
44
|
+
<td><span className={PILL_CLASS[bookingTone(b.status)]}>{b.status}</span></td>
|
|
45
|
+
</tr>
|
|
46
|
+
))}
|
|
47
|
+
</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Booking detail — one `list-row` per attendee/host, plus a reschedule-history line. */
|
|
53
|
+
export function BookingDetail({ rows }: { rows: CalcomRow[] }) {
|
|
54
|
+
const b = rows[0];
|
|
55
|
+
if (!b) return <div className="empty">No booking.</div>;
|
|
56
|
+
const attendees = Array.isArray(b.attendees) ? b.attendees : [];
|
|
57
|
+
const hosts = Array.isArray(b.hosts) ? b.hosts : [];
|
|
58
|
+
const people = [
|
|
59
|
+
...hosts.map((h: Record<string, unknown>) => ({ role: 'host', name: h.name, email: (h as { email?: string }).email ?? '', id: `host-${h.id ?? h.name}` })),
|
|
60
|
+
...attendees.map((a: Record<string, unknown>) => ({ role: 'attendee', name: a.name, email: a.email, id: `att-${a.email}` })),
|
|
61
|
+
];
|
|
62
|
+
return (
|
|
63
|
+
<section className="booking-detail" data-uid={b.uid}>
|
|
64
|
+
<h2 className="detail-title">{b.title}</h2>
|
|
65
|
+
<div className="detail-when">{formatWindow(b.start, b.end)}</div>
|
|
66
|
+
<div><span className={PILL_CLASS[bookingTone(b.status)]}>{b.status}</span></div>
|
|
67
|
+
<table className="people-table">
|
|
68
|
+
<tbody>
|
|
69
|
+
{people.map((p) => (
|
|
70
|
+
<tr className="list-row" data-pid={p.id} key={p.id}>
|
|
71
|
+
<td className="cell-role">{p.role}</td>
|
|
72
|
+
<td className="cell-name">{String(p.name ?? '')}</td>
|
|
73
|
+
<td className="cell-email">{String(p.email ?? '')}</td>
|
|
74
|
+
</tr>
|
|
75
|
+
))}
|
|
76
|
+
</tbody>
|
|
77
|
+
</table>
|
|
78
|
+
{b.fromReschedule ? <div className="reschedule-history">rescheduled from {String(b.fromReschedule)}</div> : null}
|
|
79
|
+
</section>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Event types table — one `list-row` per event type. */
|
|
84
|
+
export function EventTypesList({ rows }: { rows: CalcomRow[] }) {
|
|
85
|
+
if (rows.length === 0) return <div className="empty">No event types yet.</div>;
|
|
86
|
+
return (
|
|
87
|
+
<table className="evt-table">
|
|
88
|
+
<thead><tr><th>Title</th><th>Slug</th><th>Length</th></tr></thead>
|
|
89
|
+
<tbody>
|
|
90
|
+
{rows.map((e) => (
|
|
91
|
+
<tr className="list-row" data-id={e.id} key={e.id}>
|
|
92
|
+
<td className="cell-title">{e.title}</td>
|
|
93
|
+
<td>{e.slug}</td>
|
|
94
|
+
<td>{e.lengthInMinutes} min</td>
|
|
95
|
+
</tr>
|
|
96
|
+
))}
|
|
97
|
+
</tbody>
|
|
98
|
+
</table>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function App() {
|
|
103
|
+
const [section, setSection] = useState<SectionKey>('bookings');
|
|
104
|
+
const [rows, setRows] = useState<CalcomRow[]>([]);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const sec = SECTIONS.find((s) => s.key === section)!;
|
|
107
|
+
fetchData(sec.path).then(setRows).catch(() => setRows([]));
|
|
108
|
+
}, [section]);
|
|
109
|
+
return (
|
|
110
|
+
<div className="app">
|
|
111
|
+
<header className="topbar"><span className="brand">Cal.com twin</span></header>
|
|
112
|
+
<nav className="sidebar">
|
|
113
|
+
{SECTIONS.map((s) => (
|
|
114
|
+
<button key={s.key} className={`nav-item${s.key === section ? ' active' : ''}`} onClick={() => setSection(s.key)}>{s.label}</button>
|
|
115
|
+
))}
|
|
116
|
+
</nav>
|
|
117
|
+
<main className="content">
|
|
118
|
+
{section === 'bookings' ? <BookingsList rows={rows} /> : <EventTypesList rows={rows} />}
|
|
119
|
+
</main>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const el = typeof document !== 'undefined' ? document.getElementById('root') : null;
|
|
125
|
+
if (el) createRoot(el).render(<App />);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { RateBudget, type RateBudgetDeclaration, type RateBudgetOptions, type RateBudgetReservation, type RateBudgetSnapshot } from '@volter/world-core';
|
|
2
|
+
/** Rolling window, in ms. Spend older than this is pruned. */
|
|
3
|
+
export declare const CALCOM_BUDGET_WINDOW_MS = 60000;
|
|
4
|
+
/**
|
|
5
|
+
* Weighted units allowed inside one window. 60/60s = 60 calls a minute at the default weight —
|
|
6
|
+
* half Cal.com's documented 120 requests/minute per API key.
|
|
7
|
+
*/
|
|
8
|
+
export declare const CALCOM_BUDGET_CEILING = 60;
|
|
9
|
+
/** Seconds. A `Retry-After` above this means the key is throttled hard — fail loudly, don't sleep. */
|
|
10
|
+
export declare const CALCOM_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
11
|
+
/** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is documented vs. judged. */
|
|
12
|
+
export declare const CALCOM_CALL_WEIGHTS: {
|
|
13
|
+
/** `POST /bookings` — sends e-mail, writes real calendars, fires webhooks. Human blast radius. */
|
|
14
|
+
readonly book: 3;
|
|
15
|
+
/** `GET /slots` — availability computed across every connected calendar. The expensive read. */
|
|
16
|
+
readonly slots: 2;
|
|
17
|
+
/** Everything else: booking/event-type reads, updates, cancellations, schedules, me. */
|
|
18
|
+
readonly other: 1;
|
|
19
|
+
};
|
|
20
|
+
/** THE PACK'S DECLARATION — pure data, the only Cal.com-specific thing in the whole budget. */
|
|
21
|
+
export declare const CALCOM_RATE_BUDGET: RateBudgetDeclaration;
|
|
22
|
+
/**
|
|
23
|
+
* Price one call. The key is `"<METHOD> <path>"` (the v2 path as the connector states it, without
|
|
24
|
+
* the `/v2` prefix the live executor adds) with the query string split off, so a rule can price by
|
|
25
|
+
* method without the kernel knowing anything about Cal.com. An unclassified endpoint still costs
|
|
26
|
+
* `defaultWeight` — nothing is ever free.
|
|
27
|
+
*/
|
|
28
|
+
export declare function calcomCallWeight(method: string, path: string): number;
|
|
29
|
+
/** Where Cal.com's ledger lives. Token-keyed and cwd-independent by default (the limit is per API
|
|
30
|
+
* key, so a cwd-scoped ledger would hand the same key a fresh allowance in every checkout,
|
|
31
|
+
* worktree and CI matrix leg); pass `root` to opt into world-scoped accounting. */
|
|
32
|
+
export declare function calcomBudgetPath(opts?: {
|
|
33
|
+
root?: string;
|
|
34
|
+
token?: string;
|
|
35
|
+
} | string): string;
|
|
36
|
+
/** Construction options for Cal.com's budget. The vendor is fixed; everything else may only TIGHTEN. */
|
|
37
|
+
export type CalcomBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
38
|
+
/**
|
|
39
|
+
* Cal.com's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
|
|
40
|
+
* not an alias, so `budget instanceof CalcomBudget` in `liveCalcomExecute` means "a budget that
|
|
41
|
+
* accounts against CAL.COM's ledger under CAL.COM's ceiling": another vendor's `RateBudget` (with
|
|
42
|
+
* its own, possibly larger, ceiling) is NOT assignable there.
|
|
43
|
+
*/
|
|
44
|
+
export declare class CalcomBudget extends RateBudget {
|
|
45
|
+
constructor(opts?: CalcomBudgetOptions);
|
|
46
|
+
}
|
|
47
|
+
/** The typed refusal. One error class shared with every other vendor's budget; `err.vendor` says
|
|
48
|
+
* which one refused, and `err.kind` says why. */
|
|
49
|
+
export { RateBudgetError as CalcomBudgetError } from '@volter/world-core';
|
|
50
|
+
export type { RateBudgetErrorKind as CalcomBudgetErrorKind } from '@volter/world-core';
|
|
51
|
+
export type CalcomBudgetReservation = RateBudgetReservation;
|
|
52
|
+
export type CalcomBudgetSnapshot = RateBudgetSnapshot;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Cal.com's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus the thin typed
|
|
2
|
+
// bindings `liveCalcomExecute` uses. The MECHANISM — the durable token-keyed ledger, the rolling
|
|
3
|
+
// window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger —
|
|
4
|
+
// lives ONCE in the vendor-agnostic kernel (`@volter/world-core` → `rateBudget.ts`). Read that module's
|
|
5
|
+
// header for the full rationale AND for the honest list of what the guard does not guarantee (an
|
|
6
|
+
// injected clock or ledger path still defeats it — it guards carelessness, not malice).
|
|
7
|
+
//
|
|
8
|
+
// ── WHY THIS EXISTS ─────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// A real ~4.5-DAY vendor lockout (Figma, 2026-07-25) happened because raw API calls were made
|
|
10
|
+
// outside the pack's connector — no cache, no batching, no ceiling. Discipline only binds the code
|
|
11
|
+
// that follows it; a BUDGET binds the code that does not.
|
|
12
|
+
//
|
|
13
|
+
// ── HOW THE CEILING WAS CHOSEN ──────────────────────────────────────────────────────────────
|
|
14
|
+
// Cal.com DOES publish a scalar limit, so this models the real thing rather than guessing. From
|
|
15
|
+
// https://cal.com/docs/api-reference/v2/introduction (read 2026-07-26): API-key authentication is
|
|
16
|
+
// limited to 120 requests per minute (raisable on request); the same 120/minute is the default for
|
|
17
|
+
// unauthenticated traffic.
|
|
18
|
+
//
|
|
19
|
+
// The `X-RateLimit-Limit` / `-Remaining` / `-Reset` response headers this guard reads are NOT on that
|
|
20
|
+
// page — they are the conventional spelling, matched structurally by the kernel, and are read IF
|
|
21
|
+
// PRESENT and never assumed. (§9 corrected an earlier line here that attributed them to the cited
|
|
22
|
+
// page, 2026-07-26. Do not turn an observation into a citation.)
|
|
23
|
+
//
|
|
24
|
+
// The ceiling is 60 weighted units per 60s — 60 calls a minute at the default weight, HALF the
|
|
25
|
+
// documented 120/minute, so the 60-second AVERAGE stays under the vendor's own even before any
|
|
26
|
+
// weighting. It is more permissive than the kernel's undeclared fallback (30 calls/min) precisely
|
|
27
|
+
// because 120/min is documented; without that number it would not be.
|
|
28
|
+
//
|
|
29
|
+
// It bounds the average; it does NOT pace (the kernel refuses, it never sleeps — see its header).
|
|
30
|
+
// Cal.com's window is also a minute, so the shapes line up well here, but an intra-second burst can
|
|
31
|
+
// still reach the vendor first; the backstop for that is the cooldown armed from the 429 /
|
|
32
|
+
// `X-RateLimit-Remaining: 0`.
|
|
33
|
+
//
|
|
34
|
+
// ── HOW THE WEIGHTS WERE CHOSEN (and what is a judgement call) ───────────────────────────────
|
|
35
|
+
// Cal.com counts REQUESTS, so weight 1 is the faithful price and that is what reads get. Two
|
|
36
|
+
// deliberate exceptions, both judgement calls rather than published costs:
|
|
37
|
+
// • `POST /bookings` costs 3. A booking is not a database row — it sends invitation e-mail,
|
|
38
|
+
// writes to the organiser's and attendees' real connected calendars, and fires webhooks. A
|
|
39
|
+
// runaway create loop is the failure mode with a HUMAN blast radius, not just a throttle.
|
|
40
|
+
// • `GET /slots` costs 2. Availability is computed across every connected calendar for a date
|
|
41
|
+
// range, so it is the expensive read, and it is the one a naive UI polls in a loop.
|
|
42
|
+
import { declareRateBudget, rateBudgetPath, rateBudgetWeight, RateBudget, } from '@volter/world-core';
|
|
43
|
+
const VENDOR = 'calcom';
|
|
44
|
+
/** Rolling window, in ms. Spend older than this is pruned. */
|
|
45
|
+
export const CALCOM_BUDGET_WINDOW_MS = 60_000;
|
|
46
|
+
/**
|
|
47
|
+
* Weighted units allowed inside one window. 60/60s = 60 calls a minute at the default weight —
|
|
48
|
+
* half Cal.com's documented 120 requests/minute per API key.
|
|
49
|
+
*/
|
|
50
|
+
export const CALCOM_BUDGET_CEILING = 60;
|
|
51
|
+
/** Seconds. A `Retry-After` above this means the key is throttled hard — fail loudly, don't sleep. */
|
|
52
|
+
export const CALCOM_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
53
|
+
/** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is documented vs. judged. */
|
|
54
|
+
export const CALCOM_CALL_WEIGHTS = {
|
|
55
|
+
/** `POST /bookings` — sends e-mail, writes real calendars, fires webhooks. Human blast radius. */
|
|
56
|
+
book: 3,
|
|
57
|
+
/** `GET /slots` — availability computed across every connected calendar. The expensive read. */
|
|
58
|
+
slots: 2,
|
|
59
|
+
/** Everything else: booking/event-type reads, updates, cancellations, schedules, me. */
|
|
60
|
+
other: 1,
|
|
61
|
+
};
|
|
62
|
+
/** THE PACK'S DECLARATION — pure data, the only Cal.com-specific thing in the whole budget. */
|
|
63
|
+
export const CALCOM_RATE_BUDGET = {
|
|
64
|
+
windowMs: CALCOM_BUDGET_WINDOW_MS,
|
|
65
|
+
ceiling: CALCOM_BUDGET_CEILING,
|
|
66
|
+
defaultWeight: CALCOM_CALL_WEIGHTS.other,
|
|
67
|
+
maxRetryAfterSeconds: CALCOM_BUDGET_MAX_RETRY_AFTER_S,
|
|
68
|
+
rules: [
|
|
69
|
+
{ match: '^POST /bookings$', weight: CALCOM_CALL_WEIGHTS.book },
|
|
70
|
+
{ match: '^GET /slots', weight: CALCOM_CALL_WEIGHTS.slots },
|
|
71
|
+
],
|
|
72
|
+
reason: 'Cal.com documents 120 requests/minute per API key (cal.com/docs/api-reference/v2/introduction, ' +
|
|
73
|
+
'read 2026-07-26; the same 120/min is the unauthenticated default, and it is raisable on ' +
|
|
74
|
+
'request). That page documents no response headers; the X-RateLimit-* names the guard reads are ' +
|
|
75
|
+
'the conventional spelling, read if present and never assumed. 60 weighted units / 60s is 60 ' +
|
|
76
|
+
'calls a minute — HALF the documented 120, so the 60s average stays ' +
|
|
77
|
+
"under the vendor's own. It is deliberately more permissive than the kernel's undeclared " +
|
|
78
|
+
'fallback (30 calls/min) BECAUSE 120/min is documented; without that number it would not be. ' +
|
|
79
|
+
'POST /bookings costs 3 (it sends e-mail, writes real connected calendars and fires webhooks — ' +
|
|
80
|
+
'a human blast radius, not just a throttle) and GET /slots costs 2 (availability computed across ' +
|
|
81
|
+
'every connected calendar, and the endpoint a naive UI polls in a loop) — judgement calls, not ' +
|
|
82
|
+
'published costs. The window bounds the 60s AVERAGE and does not pace; the 429 / ' +
|
|
83
|
+
'`X-RateLimit-Remaining: 0` cooldown is the backstop for a sub-second burst.',
|
|
84
|
+
};
|
|
85
|
+
// Declared at module load, so merely importing this module (which `calcom-connector.ts` does) is
|
|
86
|
+
// enough to arm the real ceiling. `RateBudget` reads its policy live precisely so this declaration
|
|
87
|
+
// takes effect the moment it lands, and constructing through the subclass below (which imports this
|
|
88
|
+
// module) is what makes the ordering a non-issue in practice.
|
|
89
|
+
declareRateBudget(VENDOR, CALCOM_RATE_BUDGET);
|
|
90
|
+
/**
|
|
91
|
+
* Price one call. The key is `"<METHOD> <path>"` (the v2 path as the connector states it, without
|
|
92
|
+
* the `/v2` prefix the live executor adds) with the query string split off, so a rule can price by
|
|
93
|
+
* method without the kernel knowing anything about Cal.com. An unclassified endpoint still costs
|
|
94
|
+
* `defaultWeight` — nothing is ever free.
|
|
95
|
+
*/
|
|
96
|
+
export function calcomCallWeight(method, path) {
|
|
97
|
+
const { bare, query } = splitQuery(path);
|
|
98
|
+
// UPPER-CASE the method: `fetch` normalizes a known lowercase method before sending, so
|
|
99
|
+
// `execute('post', …)` really does issue a POST and must be priced as one.
|
|
100
|
+
return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `/v1/x?a=1` -> `{ bare: '/v1/x', query: { a: '1' } }`. Rules match the path; `whenQuery*` the query.
|
|
104
|
+
*
|
|
105
|
+
* NORMALIZED, because the anchored rules are otherwise trivially evaded (§9 finding, 2026-07-26):
|
|
106
|
+
* `fetch` upper-cases a known method before sending, so `execute('post', …)` issues a real WRITE
|
|
107
|
+
* that a `^POST ` rule would price as a read; and a trailing slash makes a path miss a `$` anchor
|
|
108
|
+
* while most routers treat it as the same endpoint. Both are input variations, not attacks, and
|
|
109
|
+
* either one silently voids the "expensive endpoints are priced up" claim the ceiling rests on.
|
|
110
|
+
*/
|
|
111
|
+
function splitQuery(path) {
|
|
112
|
+
const at = path.indexOf('?');
|
|
113
|
+
const query = {};
|
|
114
|
+
if (at !== -1)
|
|
115
|
+
for (const [k, v] of new URLSearchParams(path.slice(at + 1)))
|
|
116
|
+
query[k] = v;
|
|
117
|
+
const raw = at === -1 ? path : path.slice(0, at);
|
|
118
|
+
// Collapse a trailing slash, but never turn the root path into the empty string.
|
|
119
|
+
const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
|
|
120
|
+
return { bare, query };
|
|
121
|
+
}
|
|
122
|
+
/** Where Cal.com's ledger lives. Token-keyed and cwd-independent by default (the limit is per API
|
|
123
|
+
* key, so a cwd-scoped ledger would hand the same key a fresh allowance in every checkout,
|
|
124
|
+
* worktree and CI matrix leg); pass `root` to opt into world-scoped accounting. */
|
|
125
|
+
export function calcomBudgetPath(opts = {}) {
|
|
126
|
+
const o = typeof opts === 'string' ? { root: opts } : opts;
|
|
127
|
+
// VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
|
|
128
|
+
// excess-property check only catches object literals) must not redirect this pack's ledger to
|
|
129
|
+
// another vendor's file.
|
|
130
|
+
return rateBudgetPath({ ...o, vendor: VENDOR });
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Cal.com's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
|
|
134
|
+
* not an alias, so `budget instanceof CalcomBudget` in `liveCalcomExecute` means "a budget that
|
|
135
|
+
* accounts against CAL.COM's ledger under CAL.COM's ceiling": another vendor's `RateBudget` (with
|
|
136
|
+
* its own, possibly larger, ceiling) is NOT assignable there.
|
|
137
|
+
*/
|
|
138
|
+
export class CalcomBudget extends RateBudget {
|
|
139
|
+
constructor(opts = {}) {
|
|
140
|
+
super({ ...opts, vendor: VENDOR });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** The typed refusal. One error class shared with every other vendor's budget; `err.vendor` says
|
|
144
|
+
* which one refused, and `err.kind` says why. */
|
|
145
|
+
export { RateBudgetError as CalcomBudgetError } from '@volter/world-core';
|