slotlock 0.6.2 → 0.6.3
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/CHANGELOG.md +25 -0
- package/README.md +13 -6
- package/agent-server.js +1 -5
- package/agent-store-backend.d.ts +5 -6
- package/agent-store-backend.js +41 -67
- package/api-keys.js +1 -1
- package/application-api.d.ts +3 -3
- package/application-api.js +16 -24
- package/assistant.d.ts +18 -27
- package/assistant.js +36 -53
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +17 -2
- package/config.js +44 -6
- package/dashboard.js +13 -0
- package/ddl.d.ts +20 -27
- package/ddl.js +166 -108
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +23 -31
- package/event-feed.js +30 -45
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/interop.js +1 -4
- package/maintenance.d.ts +14 -10
- package/maintenance.js +43 -26
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.js +0 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/rate-limit.d.ts +6 -1
- package/rate-limit.js +24 -8
- package/rest-api.d.ts +13 -14
- package/rest-api.js +11 -12
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/self-host.d.ts +16 -2
- package/self-host.js +42 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +109 -161
- package/store.js +253 -371
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/timezone.d.ts +25 -33
- package/timezone.js +37 -52
- package/types.d.ts +35 -56
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/mcp-app.d.ts
CHANGED
|
@@ -5,16 +5,14 @@ export declare const SLOTLOCK_MCP_APP_RESOURCE_URI = "ui://slotlock/calendar";
|
|
|
5
5
|
/** True only when the host declares the stable extension's required HTML MIME capability. */
|
|
6
6
|
export declare function clientSupportsSlotlockMcpApp(capabilities: unknown): boolean;
|
|
7
7
|
/**
|
|
8
|
-
* A static, network-dark calendar view.
|
|
9
|
-
* `ui/notifications/tool-result` channel; the template
|
|
10
|
-
* textContent
|
|
11
|
-
*
|
|
8
|
+
* A static, network-dark calendar view. Data arrives only through the host's
|
|
9
|
+
* `ui/notifications/tool-result` channel; the template holds no tenant data and renders untrusted
|
|
10
|
+
* values with textContent. Reviewed source rather than generated UI, so hosts can inspect and cache
|
|
11
|
+
* it.
|
|
12
12
|
*
|
|
13
|
-
* A certain slot
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* where Slotlock asks a person to confirm the write if it is set to. The view itself never calls a
|
|
17
|
-
* tool, and every write tool is visible to the model only.
|
|
13
|
+
* A certain slot can be picked: the view sends one `ui/message` naming only the resource id and its
|
|
14
|
+
* two instants, so the agent books it through the normal path (with confirmation if configured).
|
|
15
|
+
* The view never calls a tool.
|
|
18
16
|
*/
|
|
19
17
|
export declare const SLOTLOCK_MCP_APP_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>Slotlock calendar</title>\n <style>\n :root{color-scheme:light dark;--fb-bg:#fbfbf9;--fb-surface:#ffffff;--fb-sunken:#f3f4f0;--fb-text:#171915;--fb-muted:#5d655b;--fb-faint:#8a9187;--fb-line:#e3e5df;--fb-line-strong:#c9cdc4;--fb-inverse:#171915;--fb-on-inverse:#ffffff;--fb-ring:#2e5bff;--fb-ok:#23855b;--fb-warn:#b7791f;--fb-danger:#9b2c2c}\n @media (prefers-color-scheme:dark){:root:not([data-theme=\"light\"]){--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}}\n :root[data-theme=\"dark\"]{--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}\n :root{--bg:var(--color-background-primary,var(--fb-bg));--surface:var(--color-background-secondary,var(--fb-surface));--sunken:var(--color-background-tertiary,var(--fb-sunken));--text:var(--color-text-primary,var(--fb-text));--muted:var(--color-text-secondary,var(--fb-muted));--faint:var(--color-text-tertiary,var(--fb-faint));--line:var(--color-border-primary,var(--fb-line));--line-strong:var(--color-border-secondary,var(--fb-line-strong));--inverse:var(--color-background-inverse,var(--fb-inverse));--on-inverse:var(--color-text-inverse,var(--fb-on-inverse));--ring:var(--color-ring-primary,var(--fb-ring));--ok:var(--color-text-success,var(--fb-ok));--danger:var(--color-text-danger,var(--fb-danger));font-family:var(--font-sans,ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif);color:var(--text);background:var(--bg)}\n *{box-sizing:border-box}body{margin:0;min-width:0;background:transparent;-webkit-font-smoothing:antialiased}button,input,textarea{font:inherit;color:inherit}p{margin:0}\n button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--ring);outline-offset:2px}\n .shell{display:grid;gap:12px;padding:16px;max-width:760px;margin:0 auto}\n .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n .status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}.dot{width:7px;height:7px;border-radius:50%;background:var(--fb-warn)}.status[data-ready=\"true\"] .dot{background:var(--ok)}\n /* The picker */\n .picker{display:grid;border:1px solid var(--line);border-radius:16px;background:var(--surface);overflow:hidden}.picker[hidden]{display:none}\n .head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:16px 16px 12px}\n .head h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em}\n .meta{margin-top:4px;font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 10px}.meta span+span::before{content:\"\u00B7\";margin-right:10px;color:var(--faint)}\n .clock{display:inline-flex;padding:2px;border-radius:8px;background:var(--sunken);flex:none}.clock button{min-width:40px;padding:4px 8px;border:0;border-radius:6px;background:transparent;font-size:12px;color:var(--muted);cursor:pointer}.clock button[aria-pressed=\"true\"]{background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.08)}\n .days{display:flex;gap:6px;padding:0 16px 12px;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity}.days::-webkit-scrollbar{display:none}.days[hidden]{display:none}\n .day{flex:none;display:grid;justify-items:center;gap:1px;width:56px;padding:8px 0 7px;border:1px solid var(--line);border-radius:12px;background:transparent;cursor:pointer;scroll-snap-align:start;font-variant-numeric:tabular-nums}\n .day small{font-size:11px;color:var(--muted)}.day strong{font-size:17px;font-weight:var(--font-weight-semibold,600);line-height:1.2}.day i{font-style:normal;font-size:10px;color:var(--faint)}\n .day:hover{border-color:var(--line-strong)}.day[aria-pressed=\"true\"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.day[aria-pressed=\"true\"] small,.day[aria-pressed=\"true\"] i{color:inherit;opacity:.75}\n .times{display:grid;gap:14px;padding:14px 16px 16px;border-top:1px solid var(--line)}.times[hidden],.choice[hidden]{display:none}.foot:has(.choice[hidden]){justify-content:flex-end}\n .part h2{margin:0 0 8px;font-size:11px;font-weight:var(--font-weight-medium,500);letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}\n .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}\n .time{display:grid;justify-items:center;gap:1px;min-height:42px;padding:8px 6px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface);cursor:pointer;font-variant-numeric:tabular-nums;font-weight:var(--font-weight-medium,500);transition:border-color .12s,background-color .12s}\n .time small{font-size:10px;font-weight:400;color:var(--muted)}.time:hover:not(:disabled){border-color:var(--text)}\n .time[aria-pressed=\"true\"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.time[aria-pressed=\"true\"] small{color:inherit;opacity:.75}.time:disabled{cursor:default}.time:disabled:not([aria-pressed=\"true\"]){opacity:.45}\n .one{display:grid;gap:4px;padding:4px 16px 16px}.one strong{font-size:var(--font-heading-md-size,22px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em;font-variant-numeric:tabular-nums}.one span{font-size:14px;color:var(--muted)}.one[hidden]{display:none}\n .foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;border-top:1px solid var(--line);background:var(--sunken)}\n .choice{min-width:0;font-size:13px;color:var(--muted);overflow-wrap:anywhere}.choice b{display:block;color:var(--text);font-weight:var(--font-weight-semibold,600)}\n .book{flex:none;min-height:40px;padding:9px 16px;border:0;border-radius:10px;background:var(--inverse);color:var(--on-inverse);font-weight:var(--font-weight-semibold,600);cursor:pointer}.book:disabled{cursor:default;opacity:.4}.book[data-sent=\"true\"]{opacity:1;background:transparent;color:var(--ok);padding-inline:4px}\n .fallback{display:grid;gap:6px;padding:12px 16px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}.fallback[hidden]{display:none}.fallback textarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line-strong);border-radius:8px;background:var(--bg);font:12px/1.5 var(--font-mono,ui-monospace,monospace);resize:vertical}\n /* Every other result */\n .list[hidden]{display:none}.list{display:grid;gap:12px}\n .mast{display:flex;align-items:center;justify-content:space-between;gap:12px}.mast h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600)}\n .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.search{flex:1;min-width:min(100%,220px);min-height:38px;padding:8px 12px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface)}.summary{font-size:13px;color:var(--muted)}\n .panel{min-width:0;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden}.items{display:grid;min-width:0}\n .item{display:grid;grid-template-columns:minmax(120px,.85fr) minmax(0,1.5fr) auto;gap:12px;align-items:start;padding:12px 14px;border-bottom:1px solid var(--line)}.item:last-child{border-bottom:0}.item-title{font-weight:var(--font-weight-semibold,600);overflow-wrap:anywhere}.item-detail{min-width:0;color:var(--muted);font-size:13px;line-height:1.45;overflow-wrap:anywhere}.badge{justify-self:end;padding:3px 7px;border:1px solid var(--line);border-radius:6px;font-size:11px;color:var(--muted);white-space:nowrap}.empty{padding:28px 16px;text-align:center;color:var(--muted);font-size:14px}\n details{border-top:1px solid var(--line)}summary{cursor:pointer;padding:10px 14px;color:var(--muted);font-size:12px}pre{margin:0;padding:14px;max-height:260px;overflow:auto;font:12px/1.5 var(--font-mono,ui-monospace,monospace);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--sunken)}\n @media(max-width:520px){.shell{padding:10px}.item{grid-template-columns:1fr}.badge{justify-self:start}.grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr))}.foot{flex-wrap:wrap}.book{flex:1}}\n @media(prefers-reduced-motion:reduce){.time{transition:none}}\n </style>\n</head>\n<body>\n <main class=\"shell\">\n <section class=\"picker\" id=\"picker\" aria-labelledby=\"pickTitle\" hidden>\n <div class=\"head\">\n <div><h1 id=\"pickTitle\">Pick a time</h1><p class=\"meta\" id=\"pickMeta\"></p></div>\n <div class=\"clock\" role=\"group\" aria-label=\"Clock\"><button type=\"button\" id=\"h12\" aria-pressed=\"false\">12h</button><button type=\"button\" id=\"h24\" aria-pressed=\"false\">24h</button></div>\n </div>\n <div class=\"days\" id=\"days\" role=\"group\" aria-label=\"Day\"></div>\n <div class=\"one\" id=\"one\" hidden></div>\n <div class=\"times\" id=\"times\"></div>\n <div class=\"foot\">\n <p class=\"choice\" id=\"choice\" aria-live=\"polite\">No time picked</p>\n <button type=\"button\" class=\"book\" id=\"book\" disabled>Book this time</button>\n </div>\n <div class=\"fallback\" id=\"fallback\" hidden><p>Your host did not pass the choice on. Send this to your agent:</p><textarea id=\"fallbackText\" readonly></textarea></div>\n </section>\n <section class=\"list\" id=\"list\">\n <header class=\"mast\"><h1>Slotlock calendar</h1><p class=\"status\" id=\"status\" data-ready=\"false\" role=\"status\"><span class=\"dot\" aria-hidden=\"true\"></span><span id=\"statusText\">Connecting to host</span></p></header>\n <div class=\"toolbar\">\n <label class=\"sr\" for=\"filter\">Filter this result</label><input id=\"filter\" class=\"search\" type=\"search\" autocomplete=\"off\" placeholder=\"Filter by resource, event or time\">\n <p class=\"summary\" id=\"summary\" aria-live=\"polite\">Waiting for a calendar result.</p>\n </div>\n <div class=\"panel\">\n <div class=\"items\" id=\"items\"><p class=\"empty\">Ask your agent to list resources, inspect free-busy, find a slot, or list events.</p></div>\n <details><summary>Structured result</summary><pre id=\"raw\">No result yet.</pre></details>\n </div>\n </section>\n </main>\n <script>\n (() => {\n 'use strict';\n const state = { data: null, query: '', requestId: 1, nextId: 2, timeZone: undefined, locale: undefined, hour12: undefined, day: null, chosen: null, sent: false, pending: {}, size: '' };\n const $ = (id) => document.getElementById(id);\n const status = $('status'), statusText = $('statusText'), items = $('items'), summary = $('summary'), raw = $('raw'), filter = $('filter');\n const picker = $('picker'), list = $('list'), days = $('days'), times = $('times'), one = $('one'), choice = $('choice'), book = $('book'), pickMeta = $('pickMeta'), fallback = $('fallback'), fallbackText = $('fallbackText'), h12 = $('h12'), h24 = $('h24');\n const safeText = (value) => value === null || value === undefined ? '\u2014' : String(value);\n const post = (message) => window.parent.postMessage(message, '*');\n const setStatus = (text, ready) => { statusText.textContent = text; status.dataset.ready = ready ? 'true' : 'false'; };\n const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };\n const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; };\n\n // The host's look: its standard theme variables (only these names, as plain values) and its theme.\n const THEME_KEYS = /^--(color-(background|text|border|ring)-[a-z]+|font-(sans|mono)|font-weight-[a-z]+|font-(text|heading)-[a-z]+-size)$/;\n const applyHost = (context) => {\n if (!context || typeof context !== 'object') return;\n if (context.theme === 'light' || context.theme === 'dark') document.documentElement.dataset.theme = context.theme;\n const variables = context.styles && context.styles.variables;\n if (variables && typeof variables === 'object') {\n Object.entries(variables).forEach(([key, value]) => {\n if (THEME_KEYS.test(key) && typeof value === 'string' && value.length <= 200 && !/url\\(|expression|[;{}<>]/i.test(value)) document.documentElement.style.setProperty(key, value);\n });\n }\n if (typeof context.timeZone === 'string') state.timeZone = context.timeZone;\n if (typeof context.locale === 'string') state.locale = context.locale;\n };\n\n // Only what the view has checked goes into the message a person sends: an id of plain\n // characters and two instants it parsed itself, never a title or any other server text.\n const SAFE_ID = /^[A-Za-z0-9._:-]{1,200}$/;\n const instant = (value) => { const time = typeof value === 'string' ? Date.parse(value) : NaN; return Number.isFinite(time) ? new Date(time) : null; };\n const HOLD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n const pickable = (slot) => {\n if (!slot || typeof slot.resource_id !== 'string' || !SAFE_ID.test(slot.resource_id)) return null;\n const start = instant(slot.start); const end = instant(slot.end);\n if (!start || !end || end <= start) return null;\n if (slot.hold_id === undefined) return { resource_id: slot.resource_id, start, end };\n // A held time (slotlock_hold_slots): booking it names the hold, so it needs a plain id.\n const expires = instant(slot.expires_at);\n return typeof slot.hold_id === 'string' && HOLD_ID.test(slot.hold_id) && expires\n ? { resource_id: slot.resource_id, start, end, hold_id: slot.hold_id, expires }\n : null;\n };\n const slotsOf = (data) => {\n if (!data || typeof data !== 'object' || data.error) return [];\n // Held time is free for this agent alone until it expires: certain by being held.\n if (Array.isArray(data.holds)) return data.holds.map(pickable).filter((slot) => slot && slot.hold_id);\n if (data.coverage?.certainty !== 'certain') return [];\n const listed = Array.isArray(data.slots) ? data.slots : 'start' in data ? [data] : [];\n return listed.map(pickable).filter(Boolean);\n };\n\n const fmt = (date, options) => {\n const zone = state.timeZone ? { timeZone: state.timeZone } : {};\n try { return new Intl.DateTimeFormat(state.locale, { ...options, ...zone }).format(date); }\n catch { return new Intl.DateTimeFormat(undefined, options).format(date); }\n };\n const prefers12 = () => { try { const cycle = new Intl.DateTimeFormat(state.locale, { hour: 'numeric' }).resolvedOptions().hourCycle; return cycle === 'h11' || cycle === 'h12'; } catch { return false; } };\n const hour12 = () => state.hour12 === undefined ? prefers12() : state.hour12;\n const dayKey = (date) => fmt(date, { year: 'numeric', month: '2-digit', day: '2-digit' });\n const dayLabel = (date) => fmt(date, { weekday: 'short', day: 'numeric', month: 'short' });\n const timeLabel = (date) => fmt(date, { hour: hour12() ? 'numeric' : '2-digit', minute: '2-digit', hour12: hour12() });\n // The hour as a number in any locale: German formats it as \"10 Uhr\", so read the hour part.\n const hourOf = (date) => {\n const zone = state.timeZone ? { timeZone: state.timeZone } : {};\n let parts;\n try { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23', ...zone }).formatToParts(date); }\n catch { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23' }).formatToParts(date); }\n return Number(parts.find((part) => part.type === 'hour')?.value) % 24;\n };\n const minutes = (slot) => Math.round((slot.end - slot.start) / 60000);\n const lengthLabel = (total) => total < 60 ? total + ' min' : Math.floor(total / 60) + ' h' + (total % 60 ? ' ' + (total % 60) + ' min' : '');\n const sentence = (slot) => 'Book the slot I picked: ' + (slot.hold_id ? 'hold ' + slot.hold_id + ', ' : '') + 'resource ' + slot.resource_id + ', from ' + slot.start.toISOString() + ' to ' + slot.end.toISOString() + ' (' + dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (state.timeZone ? ' ' + state.timeZone : '') + ').';\n\n const renderPicker = (slots) => {\n const resources = [...new Set(slots.map((slot) => slot.resource_id))];\n const lengths = new Set(slots.map(minutes));\n clear(pickMeta);\n if (lengths.size === 1) pickMeta.append(el('span', '', lengthLabel(minutes(slots[0]))));\n if (resources.length > 1) pickMeta.append(el('span', '', resources.length + ' resources'));\n pickMeta.append(el('span', '', state.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local time'));\n const held = slots.filter((slot) => slot.expires).map((slot) => slot.expires).sort((a, b) => a - b)[0];\n if (held) pickMeta.append(el('span', 'held', 'Held until ' + timeLabel(held)));\n h12.setAttribute('aria-pressed', String(hour12())); h24.setAttribute('aria-pressed', String(!hour12()));\n const byDay = new Map();\n slots.forEach((slot) => { const key = dayKey(slot.start); if (!byDay.has(key)) byDay.set(key, []); byDay.get(key).push(slot); });\n if (!byDay.has(state.day)) state.day = byDay.keys().next().value;\n const single = slots.length === 1;\n $('pickTitle').textContent = single ? 'Next available' : 'Pick a time';\n clear(days); clear(times); clear(one);\n days.hidden = single || byDay.size < 2;\n one.hidden = !single;\n times.hidden = single;\n if (single) {\n if (!state.chosen) state.chosen = slots[0];\n one.append(el('strong', '', timeLabel(slots[0].start) + '\u2013' + timeLabel(slots[0].end)), el('span', '', fmt(slots[0].start, { weekday: 'long', day: 'numeric', month: 'long' })));\n } else {\n byDay.forEach((daySlots, key) => {\n const first = daySlots[0].start;\n const button = el('button', 'day');\n button.type = 'button';\n button.setAttribute('aria-pressed', String(key === state.day));\n button.setAttribute('aria-label', dayLabel(first) + ', ' + daySlots.length + (daySlots.length === 1 ? ' time' : ' times'));\n button.append(el('small', '', fmt(first, { weekday: 'short' })), el('strong', '', fmt(first, { day: 'numeric' })), el('i', '', fmt(first, { month: 'short' })));\n button.addEventListener('click', () => { state.day = key; render(); });\n days.append(button);\n });\n const parts = [['Morning', (h) => h < 12], ['Afternoon', (h) => h >= 12 && h < 17], ['Evening', (h) => h >= 17]];\n const daySlots = byDay.get(state.day) || [];\n parts.forEach(([name, test]) => {\n const inPart = daySlots.filter((slot) => test(hourOf(slot.start)));\n if (inPart.length === 0) return;\n const part = el('section', 'part'); const grid = el('div', 'grid');\n part.append(el('h2', '', name), grid);\n inPart.forEach((slot) => {\n const button = el('button', 'time', timeLabel(slot.start));\n button.type = 'button';\n const isChosen = state.chosen && state.chosen.resource_id === slot.resource_id && +state.chosen.start === +slot.start;\n button.setAttribute('aria-pressed', String(Boolean(isChosen)));\n button.setAttribute('aria-label', dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (resources.length > 1 ? ', resource ' + (resources.indexOf(slot.resource_id) + 1) : ''));\n if (resources.length > 1) button.append(el('small', '', 'Resource ' + (resources.indexOf(slot.resource_id) + 1)));\n else if (lengths.size > 1) button.append(el('small', '', lengthLabel(minutes(slot))));\n button.disabled = state.sent;\n button.addEventListener('click', () => { state.chosen = slot; fallback.hidden = true; render(); });\n grid.append(button);\n });\n times.append(part);\n });\n }\n clear(choice);\n choice.hidden = single;\n if (state.chosen) {\n choice.append(el('b', '', dayLabel(state.chosen.start) + ', ' + timeLabel(state.chosen.start) + '\u2013' + timeLabel(state.chosen.end)));\n if (resources.length > 1) choice.append(document.createTextNode('Resource ' + (resources.indexOf(state.chosen.resource_id) + 1)));\n } else choice.textContent = 'No time picked';\n book.disabled = !state.chosen || state.sent;\n book.dataset.sent = String(state.sent);\n book.textContent = state.sent ? '\u2713 Sent to your agent' : 'Book this time';\n };\n book.addEventListener('click', () => {\n if (!state.chosen || state.sent) return;\n state.sent = true;\n fallback.hidden = true;\n const text = sentence(state.chosen);\n const id = state.nextId++;\n state.pending[id] = text;\n setStatus('Sent to your agent', true);\n post({ jsonrpc: '2.0', id, method: 'ui/message', params: { role: 'user', content: { type: 'text', text } } });\n render();\n });\n h12.addEventListener('click', () => { state.hour12 = true; render(); });\n h24.addEventListener('click', () => { state.hour12 = false; render(); });\n\n const reportSize = () => {\n const root = document.documentElement;\n const size = { width: Math.ceil(root.scrollWidth), height: Math.ceil(root.scrollHeight) };\n const key = size.width + 'x' + size.height;\n if (key === state.size) return;\n state.size = key;\n post({ jsonrpc: '2.0', method: 'ui/notifications/size-changed', params: size });\n };\n const intervalText = (entry) => [entry.start || entry.starts_at, entry.end || entry.ends_at].filter(Boolean).join(' \u2192 ');\n // A refused call carries no structuredContent (hosts validate it against the tool's\n // outputSchema); its text content is {\"error\":{\"code\":\u2026}}.\n const refusalOf = (content) => {\n const text = Array.isArray(content) ? content.find((part) => part && part.type === 'text')?.text : undefined;\n try {\n const parsed = JSON.parse(text);\n if (parsed && parsed.error && typeof parsed.error === 'object') return { error: parsed.error };\n } catch {}\n return { error: { code: 'request_refused' } };\n };\n const flatten = (data) => {\n if (!data || typeof data !== 'object') return [];\n if (data.error) return [{ title: data.error.code || 'Unable to load', detail: 'The calendar server refused this request.', badge: 'Error' }];\n if (Array.isArray(data.resources)) return data.resources.flatMap((resource) => {\n if (Array.isArray(resource.busy)) return resource.busy.map((busy) => ({ title: resource.resource_id, detail: intervalText(busy), badge: resource.coverage?.certainty || 'busy' }));\n return [{ title: resource.external_ref || resource.id || resource.resource_id, detail: resource.timezone || 'Calendar resource', badge: resource.coverage?.certainty || 'resource' }];\n });\n if (Array.isArray(data.slots)) {\n if (data.coverage?.certainty !== 'certain') return [{ title: 'Availability unproven', detail: 'Required calendar coverage is incomplete.', badge: data.coverage?.certainty || 'uncertain' }];\n return data.slots.length === 0 ? [{ title: 'No free time', detail: 'Nothing of that length is free in the window.', badge: 'certain' }] : data.slots.map((slot) => ({ title: slot.resource_id, detail: intervalText(slot), badge: 'free' }));\n }\n if (Array.isArray(data.holds)) return [{ title: 'Nothing held', detail: 'Every time asked for was taken.', badge: 'refused' }];\n if (Array.isArray(data.events)) return data.events.map((event) => ({ title: event.title || event.id, detail: intervalText(event), badge: event.status || event.transparency || 'event' }));\n if (data.event && typeof data.event === 'object') return [{ title: data.event.title || data.event.id, detail: intervalText(data.event), badge: data.event.status || 'event' }];\n if ('start' in data || 'end' in data) {\n const coverage = data.coverage && typeof data.coverage === 'object' ? data.coverage : null;\n const certainty = coverage?.certainty;\n const completeInterval = typeof data.start === 'string' && typeof data.end === 'string';\n if (certainty !== 'certain' || !completeInterval) {\n const reason = typeof coverage?.reason === 'string' ? coverage.reason.replaceAll('_', ' ') : 'Required calendar coverage is incomplete.';\n return [{ title: 'Availability unproven', detail: reason, badge: certainty || 'uncertain' }];\n }\n return [{ title: data.resource_id || 'Next available', detail: intervalText(data), badge: certainty }];\n }\n return Object.entries(data).slice(0, 100).map(([key, value]) => ({ title: key, detail: typeof value === 'object' ? JSON.stringify(value) : safeText(value), badge: 'value' }));\n };\n const line = (title, detail, badge) => {\n const row = el('article', 'item');\n row.append(el('div', 'item-title', safeText(title)), el('div', 'item-detail', safeText(detail)), el('div', 'badge', safeText(badge)));\n return row;\n };\n const render = () => {\n const slots = slotsOf(state.data);\n picker.hidden = slots.length === 0;\n list.hidden = slots.length > 0;\n if (slots.length > 0) { renderPicker(slots); reportSize(); return; }\n clear(items);\n const rows = flatten(state.data).filter((entry) => (entry.title + ' ' + entry.detail + ' ' + entry.badge).toLowerCase().includes(state.query));\n if (rows.length === 0) items.append(el('p', 'empty', state.data ? 'No rows match this filter.' : 'Waiting for a calendar result.'));\n else rows.forEach((entry) => items.append(line(entry.title, entry.detail, entry.badge)));\n summary.textContent = rows.length === 1 ? '1 calendar row' : rows.length + ' calendar rows';\n raw.textContent = state.data ? JSON.stringify(state.data, null, 2) : 'No result yet.';\n reportSize();\n };\n filter.addEventListener('input', () => { state.query = filter.value.trim().toLowerCase(); render(); });\n window.addEventListener('message', (event) => {\n if (event.source !== window.parent) return;\n const message = event.data;\n if (!message || message.jsonrpc !== '2.0') return;\n if (message.id === state.requestId && message.result) {\n applyHost(message.result.hostContext);\n post({ jsonrpc: '2.0', method: 'ui/notifications/initialized' });\n setStatus('Connected', true);\n render();\n return;\n }\n if (message.id in state.pending) {\n const text = state.pending[message.id];\n delete state.pending[message.id];\n if (message.error) {\n // Refused or unsupported: the person can still send it, and pick again.\n state.sent = false;\n fallbackText.value = text;\n fallback.hidden = false;\n setStatus('Not sent', false);\n render();\n }\n return;\n }\n if (message.method === 'ui/notifications/tool-result') {\n const result = message.params || {};\n state.data = result.structuredContent || (result.isError ? refusalOf(result.content) : null);\n state.chosen = null; state.sent = false; state.day = null;\n fallback.hidden = true;\n setStatus(result.isError ? 'Request refused' : 'Result current', !result.isError);\n render();\n }\n if (message.method === 'ui/notifications/host-context-changed') { applyHost(message.params); render(); }\n });\n if (typeof ResizeObserver === 'function') new ResizeObserver(reportSize).observe(document.documentElement);\n post({ jsonrpc: '2.0', id: state.requestId, method: 'ui/initialize', params: { appInfo: { name: 'slotlock-calendar', version: '1.1.0' }, appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, protocolVersion: '2026-01-26' } });\n })();\n </script>\n</body>\n</html>";
|
|
20
18
|
export declare const SLOTLOCK_MCP_APP_RESOURCE: Readonly<{
|
package/mcp-app.js
CHANGED
|
@@ -16,16 +16,14 @@ export function clientSupportsSlotlockMcpApp(capabilities) {
|
|
|
16
16
|
return Array.isArray(mimeTypes) && mimeTypes.includes(SLOTLOCK_MCP_APP_MIME_TYPE);
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* A static, network-dark calendar view.
|
|
20
|
-
* `ui/notifications/tool-result` channel; the template
|
|
21
|
-
* textContent
|
|
22
|
-
*
|
|
19
|
+
* A static, network-dark calendar view. Data arrives only through the host's
|
|
20
|
+
* `ui/notifications/tool-result` channel; the template holds no tenant data and renders untrusted
|
|
21
|
+
* values with textContent. Reviewed source rather than generated UI, so hosts can inspect and cache
|
|
22
|
+
* it.
|
|
23
23
|
*
|
|
24
|
-
* A certain slot
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* where Slotlock asks a person to confirm the write if it is set to. The view itself never calls a
|
|
28
|
-
* tool, and every write tool is visible to the model only.
|
|
24
|
+
* A certain slot can be picked: the view sends one `ui/message` naming only the resource id and its
|
|
25
|
+
* two instants, so the agent books it through the normal path (with confirmation if configured).
|
|
26
|
+
* The view never calls a tool.
|
|
29
27
|
*/
|
|
30
28
|
export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
|
|
31
29
|
<html lang="en">
|
package/mcp-modern.d.ts
CHANGED
|
@@ -16,11 +16,10 @@ export interface ModernMcpRefusal {
|
|
|
16
16
|
data?: Record<string, unknown>;
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* Validate a 2026-07-28 request's `_meta` envelope and the
|
|
20
|
-
* specification's order:
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* streamable-http §Server Validation). Every refusal is HTTP 400.
|
|
19
|
+
* Validate a 2026-07-28 request's `_meta` envelope and the headers mirroring its body, in the
|
|
20
|
+
* specification's order: required `_meta` fields (-32602), `MCP-Protocol-Version` against the body
|
|
21
|
+
* (-32020), a supported revision (-32022), then `Mcp-Method` and `Mcp-Name` against the body
|
|
22
|
+
* (-32020). Every refusal is HTTP 400.
|
|
24
23
|
*/
|
|
25
24
|
export declare function checkModernMcpRequest(args: {
|
|
26
25
|
headers: Headers;
|
|
@@ -40,13 +39,13 @@ export declare function sha256Hex(value: string): string;
|
|
|
40
39
|
/** Validate and copy the HMAC keys that seal `requestState`: 1-8 keys of at least 32 bytes each. */
|
|
41
40
|
export declare function stateSecrets(secrets: readonly (string | Uint8Array)[]): Buffer[];
|
|
42
41
|
/**
|
|
43
|
-
* Seal a JSON payload for a client to
|
|
44
|
-
*
|
|
42
|
+
* Seal a JSON payload for a client to echo: `v1.<payload>.<HMAC-SHA256>`, base64url.
|
|
43
|
+
* Integrity-protected, not encrypted: never put anything the client may not see in it.
|
|
45
44
|
*/
|
|
46
45
|
export declare function sealRequestState(key: Buffer, payload: Record<string, unknown>): string;
|
|
47
46
|
/**
|
|
48
|
-
* The payload of a state this server sealed with any of `keys`, and the key
|
|
49
|
-
*
|
|
47
|
+
* The payload of a state this server sealed with any of `keys`, and the verifying key; `null`
|
|
48
|
+
* otherwise. The MAC is checked in constant time before parsing.
|
|
50
49
|
*/
|
|
51
50
|
export declare function openRequestState(keys: readonly Buffer[], state: unknown): {
|
|
52
51
|
payload: Record<string, unknown>;
|
|
@@ -69,9 +68,9 @@ export interface McpEventStream {
|
|
|
69
68
|
close(): void;
|
|
70
69
|
}
|
|
71
70
|
/**
|
|
72
|
-
* An SSE response (streamable-http §Receiving Messages):
|
|
73
|
-
*
|
|
74
|
-
*
|
|
71
|
+
* An SSE response (streamable-http §Receiving Messages): uncached and unbuffered by proxies
|
|
72
|
+
* (`X-Accel-Buffering: no`). `onCancel` runs once if the reader leaves first, which is how an HTTP
|
|
73
|
+
* client cancels.
|
|
75
74
|
*/
|
|
76
75
|
export declare function openMcpEventStream(onCancel: () => void): McpEventStream;
|
|
77
76
|
/** W3C Trace Context (and Baggage) a request carried, validated; handed to the backend as is. */
|
|
@@ -81,9 +80,8 @@ export interface SlotlockTraceContext {
|
|
|
81
80
|
baggage?: string;
|
|
82
81
|
}
|
|
83
82
|
/**
|
|
84
|
-
* Trace context from
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* dropped on its own (W3C Trace Context §3.3, Baggage §3.3).
|
|
83
|
+
* Trace context from `_meta` (MCP reserves `traceparent`, `tracestate` and `baggage` there) or, if
|
|
84
|
+
* absent, the HTTP headers; never a mix. An invalid `traceparent` discards it all; an invalid
|
|
85
|
+
* `tracestate` or `baggage` is dropped alone (W3C Trace Context §3.3, Baggage §3.3).
|
|
88
86
|
*/
|
|
89
87
|
export declare function traceContextFrom(meta: Record<string, unknown> | undefined, headers: Headers): SlotlockTraceContext | undefined;
|
package/mcp-modern.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
/*
|
|
3
|
-
* MCP 2026-07-28 wire mechanics
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* agent-server.ts, the orchestration owner.
|
|
3
|
+
* MCP 2026-07-28 wire mechanics: the per-request `_meta` envelope, Streamable HTTP headers, sealed
|
|
4
|
+
* `requestState`, SSE framing and W3C trace context. Authorization, dispatch and results stay in
|
|
5
|
+
* agent-server.ts.
|
|
7
6
|
*/
|
|
8
7
|
export const MCP_META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion';
|
|
9
8
|
const MCP_META_CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities';
|
|
@@ -23,16 +22,15 @@ const MCP_NAMED_METHODS = new Map([
|
|
|
23
22
|
export function isRecord(value) {
|
|
24
23
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
24
|
}
|
|
26
|
-
// RFC 9110 field value: visible ASCII, space and
|
|
27
|
-
//
|
|
25
|
+
// RFC 9110 field value: visible ASCII, space and tab; anything else must use the `=?base64?…?=`
|
|
26
|
+
// sentinel (Streamable HTTP §Value Encoding).
|
|
28
27
|
const PLAIN_HEADER_VALUE = /^[\x20-\x7E\t]*$/;
|
|
29
28
|
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
30
29
|
const BASE64_SENTINEL_PREFIX = '=?base64?';
|
|
31
30
|
const BASE64_SENTINEL_SUFFIX = '?=';
|
|
32
31
|
/**
|
|
33
|
-
* The value an `Mcp-Name`
|
|
34
|
-
*
|
|
35
|
-
* header-safe set.
|
|
32
|
+
* The value an `Mcp-Name` or `Mcp-Param-*` header stands for, Base64-sentinel decoded when used;
|
|
33
|
+
* `null` when malformed.
|
|
36
34
|
*/
|
|
37
35
|
function decodeMcpHeaderValue(value) {
|
|
38
36
|
if (value.length >= BASE64_SENTINEL_PREFIX.length + BASE64_SENTINEL_SUFFIX.length &&
|
|
@@ -51,11 +49,10 @@ function decodeMcpHeaderValue(value) {
|
|
|
51
49
|
return PLAIN_HEADER_VALUE.test(value) ? value : null;
|
|
52
50
|
}
|
|
53
51
|
/**
|
|
54
|
-
* Validate a 2026-07-28 request's `_meta` envelope and the
|
|
55
|
-
* specification's order:
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* streamable-http §Server Validation). Every refusal is HTTP 400.
|
|
52
|
+
* Validate a 2026-07-28 request's `_meta` envelope and the headers mirroring its body, in the
|
|
53
|
+
* specification's order: required `_meta` fields (-32602), `MCP-Protocol-Version` against the body
|
|
54
|
+
* (-32020), a supported revision (-32022), then `Mcp-Method` and `Mcp-Name` against the body
|
|
55
|
+
* (-32020). Every refusal is HTTP 400.
|
|
59
56
|
*/
|
|
60
57
|
export function checkModernMcpRequest(args) {
|
|
61
58
|
const invalid = (message) => ({ ok: false, status: 400, code: -32602, message });
|
|
@@ -139,8 +136,8 @@ export function stateSecrets(secrets) {
|
|
|
139
136
|
});
|
|
140
137
|
}
|
|
141
138
|
/**
|
|
142
|
-
* Seal a JSON payload for a client to
|
|
143
|
-
*
|
|
139
|
+
* Seal a JSON payload for a client to echo: `v1.<payload>.<HMAC-SHA256>`, base64url.
|
|
140
|
+
* Integrity-protected, not encrypted: never put anything the client may not see in it.
|
|
144
141
|
*/
|
|
145
142
|
export function sealRequestState(key, payload) {
|
|
146
143
|
const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
@@ -148,8 +145,8 @@ export function sealRequestState(key, payload) {
|
|
|
148
145
|
return `${STATE_VERSION}.${body}.${mac}`;
|
|
149
146
|
}
|
|
150
147
|
/**
|
|
151
|
-
* The payload of a state this server sealed with any of `keys`, and the key
|
|
152
|
-
*
|
|
148
|
+
* The payload of a state this server sealed with any of `keys`, and the verifying key; `null`
|
|
149
|
+
* otherwise. The MAC is checked in constant time before parsing.
|
|
153
150
|
*/
|
|
154
151
|
export function openRequestState(keys, state) {
|
|
155
152
|
if (typeof state !== 'string' || state.length > MAX_REQUEST_STATE_LENGTH)
|
|
@@ -163,8 +160,8 @@ export function openRequestState(keys, state) {
|
|
|
163
160
|
!BASE64URL.test(mac)) {
|
|
164
161
|
return null;
|
|
165
162
|
}
|
|
166
|
-
// Compared as text: decoding drops the
|
|
167
|
-
//
|
|
163
|
+
// Compared as text: decoding drops the last character's spare bits, so other spellings of the
|
|
164
|
+
// same MAC would pass.
|
|
168
165
|
const given = Buffer.from(mac);
|
|
169
166
|
for (const key of keys) {
|
|
170
167
|
const expected = Buffer.from(createHmac('sha256', key).update(`${STATE_MAC_CONTEXT}.${body}`).digest('base64url'));
|
|
@@ -196,9 +193,9 @@ const SSE_KEEP_ALIVE = encoder.encode(':\n\n');
|
|
|
196
193
|
/** Messages a reader may leave unread before the stream counts it as gone. */
|
|
197
194
|
const MAX_UNREAD_EVENTS = 256;
|
|
198
195
|
/**
|
|
199
|
-
* An SSE response (streamable-http §Receiving Messages):
|
|
200
|
-
*
|
|
201
|
-
*
|
|
196
|
+
* An SSE response (streamable-http §Receiving Messages): uncached and unbuffered by proxies
|
|
197
|
+
* (`X-Accel-Buffering: no`). `onCancel` runs once if the reader leaves first, which is how an HTTP
|
|
198
|
+
* client cancels.
|
|
202
199
|
*/
|
|
203
200
|
export function openMcpEventStream(onCancel) {
|
|
204
201
|
let open = true;
|
|
@@ -266,10 +263,9 @@ function validTraceparent(value) {
|
|
|
266
263
|
parentId !== '0'.repeat(16));
|
|
267
264
|
}
|
|
268
265
|
/**
|
|
269
|
-
* Trace context from
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* dropped on its own (W3C Trace Context §3.3, Baggage §3.3).
|
|
266
|
+
* Trace context from `_meta` (MCP reserves `traceparent`, `tracestate` and `baggage` there) or, if
|
|
267
|
+
* absent, the HTTP headers; never a mix. An invalid `traceparent` discards it all; an invalid
|
|
268
|
+
* `tracestate` or `baggage` is dropped alone (W3C Trace Context §3.3, Baggage §3.3).
|
|
273
269
|
*/
|
|
274
270
|
export function traceContextFrom(meta, headers) {
|
|
275
271
|
const fromMeta = typeof meta?.traceparent === 'string';
|
package/metrics.js
CHANGED
|
@@ -26,7 +26,6 @@ function formatLabels(labels) {
|
|
|
26
26
|
const entries = Object.entries(labels);
|
|
27
27
|
if (entries.length === 0)
|
|
28
28
|
return '';
|
|
29
|
-
// Label values are from LABEL's set or the version strings below: escaped anyway.
|
|
30
29
|
const escaped = entries.map(([name, value]) => `${name}="${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`);
|
|
31
30
|
return `{${escaped.join(',')}}`;
|
|
32
31
|
}
|
package/node-server.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ export type SlotlockNodeServerErrorPhase = 'client' | 'listen' | 'request' | 'se
|
|
|
4
4
|
/** What the listener knows about a request's connection, beside the Request itself. */
|
|
5
5
|
export interface SlotlockNodeServerConnection {
|
|
6
6
|
/**
|
|
7
|
-
* The peer's address as the socket reports it (
|
|
8
|
-
*
|
|
7
|
+
* The peer's address as the socket reports it (`::ffff:a.b.c.d` for IPv4 on a dual-stack
|
|
8
|
+
* listener). Behind a proxy it is the proxy's; never taken from a request field.
|
|
9
9
|
*/
|
|
10
10
|
remoteAddress?: string;
|
|
11
11
|
}
|
|
@@ -13,15 +13,15 @@ export interface SlotlockNodeServerConnection {
|
|
|
13
13
|
export interface SlotlockNodeServerFetchTarget {
|
|
14
14
|
fetch(request: Request, connection?: SlotlockNodeServerConnection): Promise<Response>;
|
|
15
15
|
/**
|
|
16
|
-
* Called once when `close()` begins, after the listener stops accepting
|
|
17
|
-
*
|
|
16
|
+
* Called once when `close()` begins, after the listener stops accepting, to end long-lived
|
|
17
|
+
* responses (MCP subscription streams) within the grace period.
|
|
18
18
|
*/
|
|
19
19
|
shutdown?(): Promise<void> | void;
|
|
20
20
|
}
|
|
21
21
|
export interface SlotlockNodeServerOptions {
|
|
22
22
|
/**
|
|
23
|
-
* Canonical origin
|
|
24
|
-
*
|
|
23
|
+
* Canonical origin for the Request passed to authentication and policy callbacks, explicit so an
|
|
24
|
+
* untrusted Host or forwarding header can never choose security context.
|
|
25
25
|
*/
|
|
26
26
|
requestOrigin: string;
|
|
27
27
|
handlerTimeoutMs?: number;
|
|
@@ -57,8 +57,8 @@ export interface SlotlockNodeServer {
|
|
|
57
57
|
}): Promise<SlotlockNodeServerCloseResult>;
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
* Adapt the Fetch boundary
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* Adapt the Fetch boundary from `createSlotlockAgentServer` to a hardened Node HTTP listener.
|
|
61
|
+
* HTTP-only by design: TLS termination and proxy trust are deployment concerns, and Request
|
|
62
|
+
* identity always uses the explicit `requestOrigin`.
|
|
63
63
|
*/
|
|
64
64
|
export declare function createSlotlockNodeServer(target: SlotlockNodeServerFetchTarget, options: SlotlockNodeServerOptions): SlotlockNodeServer;
|
package/node-server.js
CHANGED
|
@@ -75,16 +75,13 @@ function appendIncomingHeaders(request) {
|
|
|
75
75
|
return headers;
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
|
-
* The request body as a web stream
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* written the rest is discarded as it arrives, as Node discards a body no handler reads, within
|
|
83
|
-
* `requestTimeout`.
|
|
78
|
+
* The request body as a cancellable web stream. Cancelling a `Readable.toWeb` stream destroys the
|
|
79
|
+
* IncomingMessage (Node emits 'aborted') and leaves the unread rest on the connection, holding
|
|
80
|
+
* `close()` until forced. Cancelling this one only stops delivery; once the response is written the
|
|
81
|
+
* rest is discarded as it arrives, within `requestTimeout`.
|
|
84
82
|
*/
|
|
85
83
|
function requestBody(request) {
|
|
86
84
|
let controller;
|
|
87
|
-
// Once the stream is closed, errored or cancelled, what arrives is dropped.
|
|
88
85
|
let settled = false;
|
|
89
86
|
const stream = new ReadableStream({
|
|
90
87
|
start(started) {
|
|
@@ -112,7 +109,7 @@ function requestBody(request) {
|
|
|
112
109
|
request.resume();
|
|
113
110
|
},
|
|
114
111
|
cancel() {
|
|
115
|
-
// Held until the response is written, so the client is not read from
|
|
112
|
+
// Held until the response is written, so the client is not read from meanwhile.
|
|
116
113
|
settled = true;
|
|
117
114
|
request.pause();
|
|
118
115
|
},
|
|
@@ -120,8 +117,7 @@ function requestBody(request) {
|
|
|
120
117
|
return {
|
|
121
118
|
stream,
|
|
122
119
|
discard() {
|
|
123
|
-
// A target still reading
|
|
124
|
-
// never settles.
|
|
120
|
+
// A target still reading an answered body gets an error, not a read that never settles.
|
|
125
121
|
if (!settled) {
|
|
126
122
|
settled = true;
|
|
127
123
|
controller?.error(abortReason('request_body_discarded'));
|
|
@@ -135,9 +131,9 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
135
131
|
if (!target.startsWith('/') || target.startsWith('//')) {
|
|
136
132
|
throw new Error('invalid_request_target');
|
|
137
133
|
}
|
|
138
|
-
// URL parsing resolves dot segments, `%2E` spellings included
|
|
139
|
-
// would
|
|
140
|
-
//
|
|
134
|
+
// URL parsing resolves dot segments, `%2E` spellings included
|
|
135
|
+
// (`/v1/resources/%2E%2E/availability` would become `/v1/availability`), and treats a backslash
|
|
136
|
+
// as a slash.
|
|
141
137
|
const path = target.split(/[?#]/, 1)[0] ?? '';
|
|
142
138
|
if (path.split(/[\\/]/).some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment))) {
|
|
143
139
|
throw new Error('invalid_request_target');
|
|
@@ -145,9 +141,8 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
145
141
|
const url = new URL(target, requestOrigin);
|
|
146
142
|
if (url.origin !== requestOrigin)
|
|
147
143
|
throw new Error('invalid_request_target');
|
|
148
|
-
// Node's fetch
|
|
149
|
-
//
|
|
150
|
-
// Keep the cast at this adapter boundary; real-socket tests exercise the actual undici contract.
|
|
144
|
+
// Node's fetch needs `duplex: 'half'` for streamed bodies, which the DOM RequestInit does not
|
|
145
|
+
// declare; the cast stays at this boundary and real-socket tests cover undici's contract.
|
|
151
146
|
const init = {
|
|
152
147
|
method: request.method ?? 'GET',
|
|
153
148
|
headers: appendIncomingHeaders(request),
|
|
@@ -163,7 +158,7 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
163
158
|
return { request: new Request(url, init), ...(body ? { body } : {}) };
|
|
164
159
|
}
|
|
165
160
|
catch (error) {
|
|
166
|
-
// A method the Fetch API forbids
|
|
161
|
+
// A method the Fetch API forbids: nothing will read the body, so drop it.
|
|
167
162
|
body?.discard();
|
|
168
163
|
throw error;
|
|
169
164
|
}
|
|
@@ -212,9 +207,9 @@ function formatListenerOrigin(host, port) {
|
|
|
212
207
|
return `http://${host.includes(':') ? `[${host}]` : host}:${port}`;
|
|
213
208
|
}
|
|
214
209
|
/**
|
|
215
|
-
* Adapt the Fetch boundary
|
|
216
|
-
*
|
|
217
|
-
*
|
|
210
|
+
* Adapt the Fetch boundary from `createSlotlockAgentServer` to a hardened Node HTTP listener.
|
|
211
|
+
* HTTP-only by design: TLS termination and proxy trust are deployment concerns, and Request
|
|
212
|
+
* identity always uses the explicit `requestOrigin`.
|
|
218
213
|
*/
|
|
219
214
|
export function createSlotlockNodeServer(target, options) {
|
|
220
215
|
const requestTimeoutMs = boundedInteger('requestTimeoutMs', options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, 1, MAX_TIMEOUT_MS);
|
|
@@ -231,8 +226,8 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
231
226
|
config.onError = options.onError;
|
|
232
227
|
const activeRequests = new Set();
|
|
233
228
|
let state = 'new';
|
|
234
|
-
//
|
|
235
|
-
//
|
|
229
|
+
// A function, so TypeScript does not keep the narrowing across awaits: `close()` can change the
|
|
230
|
+
// state meanwhile.
|
|
236
231
|
const closing = () => state === 'closing';
|
|
237
232
|
let removeLifecycleAbort;
|
|
238
233
|
/** Rejects a listen() still starting with `listen_aborted`; set while one is. */
|
|
@@ -241,8 +236,8 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
241
236
|
const server = createServer({
|
|
242
237
|
headersTimeout: headersTimeoutMs,
|
|
243
238
|
requestTimeout: requestTimeoutMs,
|
|
244
|
-
// Node enforces both only when it checks
|
|
245
|
-
//
|
|
239
|
+
// Node enforces both only when it checks connections (every 30 s by default), so a trickled
|
|
240
|
+
// body could outlast requestTimeout by that much.
|
|
246
241
|
connectionsCheckingInterval: Math.min(DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS, headersTimeoutMs, requestTimeoutMs),
|
|
247
242
|
keepAliveTimeout: boundedInteger('keepAliveTimeoutMs', options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS, 1, MAX_TIMEOUT_MS),
|
|
248
243
|
maxHeaderSize: boundedInteger('maxHeaderBytes', options.maxHeaderBytes ?? DEFAULT_MAX_HEADER_BYTES, 1_024, MAX_HEADER_BYTES),
|
|
@@ -307,11 +302,9 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
307
302
|
if (timeout)
|
|
308
303
|
clearTimeout(timeout);
|
|
309
304
|
activeRequests.delete(controller);
|
|
310
|
-
// The answer is written: what is left of
|
|
311
|
-
// too large, say) is discarded as it arrives rather than held.
|
|
305
|
+
// The answer is written: discard what is left of an unread body as it arrives.
|
|
312
306
|
if (!request.complete) {
|
|
313
307
|
body?.discard();
|
|
314
|
-
// The connection is idle only once that rest has arrived: close it then if shutting down.
|
|
315
308
|
request.once('end', () => {
|
|
316
309
|
setImmediate(() => {
|
|
317
310
|
if (closing())
|
|
@@ -319,8 +312,8 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
319
312
|
});
|
|
320
313
|
});
|
|
321
314
|
}
|
|
322
|
-
//
|
|
323
|
-
//
|
|
315
|
+
// Otherwise a keep-alive connection finishing during shutdown holds `server.close()` until
|
|
316
|
+
// its idle timeout.
|
|
324
317
|
if (closing())
|
|
325
318
|
server.closeIdleConnections();
|
|
326
319
|
}
|
|
@@ -341,9 +334,8 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
341
334
|
removeLifecycleAbort?.();
|
|
342
335
|
removeLifecycleAbort = undefined;
|
|
343
336
|
if (state === 'starting') {
|
|
344
|
-
// Closed before
|
|
345
|
-
//
|
|
346
|
-
// signal rejects it.
|
|
337
|
+
// Closed before listening: Node abandons such a listen with neither 'listening' nor
|
|
338
|
+
// 'error', so reject the pending listen() here.
|
|
347
339
|
state = 'closing';
|
|
348
340
|
cancelStartup?.();
|
|
349
341
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
@@ -363,7 +355,7 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
363
355
|
});
|
|
364
356
|
server.closeIdleConnections();
|
|
365
357
|
});
|
|
366
|
-
// Not awaited: a slow
|
|
358
|
+
// Not awaited: a slow hook must not hold shutdown past its grace period.
|
|
367
359
|
void (async () => {
|
|
368
360
|
try {
|
|
369
361
|
await target.shutdown?.();
|
|
@@ -434,7 +426,6 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
434
426
|
const onListening = () => {
|
|
435
427
|
cleanupStartup();
|
|
436
428
|
if (cancelled) {
|
|
437
|
-
// close() came first; a listener that bound all the same goes with it.
|
|
438
429
|
server.close();
|
|
439
430
|
return;
|
|
440
431
|
}
|
package/package-version.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// root one), which is where it is installed from npm or copied into the image; run from a checkout's
|
|
4
|
-
// src/, the root package.json is one directory up.
|
|
1
|
+
// Read from package.json so that file is the one place the version is set: built, the artifact's
|
|
2
|
+
// own package.json sits beside this module; in a checkout's src/, the root one is a directory up.
|
|
5
3
|
import { readFileSync } from 'node:fs';
|
|
6
4
|
/** Slotlock's version, or `'unknown'` where no manifest of its own can be read (a bundle). */
|
|
7
5
|
export function slotlockPackageVersion() {
|
package/package.json
CHANGED
package/rate-limit.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type SlotlockAgentOperation, type SlotlockAgentPrincipal } from './agent-server.js';
|
|
2
2
|
import { type SlotlockApplicationOperation } from './application-api.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type SlotlockLimits, type SlotlockRate } from './config.js';
|
|
4
4
|
import type { SlotlockNodeServerFetchTarget } from './node-server.js';
|
|
5
5
|
export type SlotlockRefusalReason = 'auth_refused' | 'rate_limited' | 'lookup_overflow' | 'series_writes_busy' | 'maintenance_busy' | 'request_too_large';
|
|
6
6
|
/** One `refusals` log line: counts since the last one, and the most refused clients as pseudonyms. */
|
|
@@ -16,6 +16,11 @@ export interface SlotlockRequestLimitsOptions {
|
|
|
16
16
|
limits: SlotlockLimits;
|
|
17
17
|
/** A lower-case request field a trusted proxy sets to the client address (config.ts). */
|
|
18
18
|
trustedProxyHeader?: string;
|
|
19
|
+
/**
|
|
20
|
+
* With `trustedProxyHeader`, the value the proxy also sets in x-slotlock-proxy-proof
|
|
21
|
+
* (SLOTLOCK_TRUSTED_PROXY_SECRET). A request without it exactly is counted by its connection.
|
|
22
|
+
*/
|
|
23
|
+
trustedProxySecret?: string;
|
|
19
24
|
/** The public URL's path, without a trailing slash (`''` at the origin root). */
|
|
20
25
|
basePath: string;
|
|
21
26
|
/** Paths, under the base path, of the dashboard's sign-in and its callback, when it is served. */
|