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.
Files changed (62) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +13 -6
  3. package/agent-server.js +1 -5
  4. package/agent-store-backend.d.ts +5 -6
  5. package/agent-store-backend.js +41 -67
  6. package/api-keys.js +1 -1
  7. package/application-api.d.ts +3 -3
  8. package/application-api.js +16 -24
  9. package/assistant.d.ts +18 -27
  10. package/assistant.js +36 -53
  11. package/booking-rules.d.ts +30 -41
  12. package/booking-rules.js +17 -25
  13. package/change-watch.d.ts +1 -0
  14. package/change-watch.js +1 -1
  15. package/cli.js +3 -4
  16. package/config.d.ts +17 -2
  17. package/config.js +44 -6
  18. package/dashboard.js +13 -0
  19. package/ddl.d.ts +20 -27
  20. package/ddl.js +166 -108
  21. package/engine.d.ts +6 -9
  22. package/engine.js +5 -9
  23. package/event-feed.d.ts +23 -31
  24. package/event-feed.js +30 -45
  25. package/event-schema.js +6 -9
  26. package/hooks.d.ts +2 -4
  27. package/hooks.js +2 -4
  28. package/ical-worker-thread.js +3 -4
  29. package/ical-worker.d.ts +8 -10
  30. package/ical-worker.js +7 -9
  31. package/interop.js +1 -4
  32. package/maintenance.d.ts +14 -10
  33. package/maintenance.js +43 -26
  34. package/mcp-app.d.ts +7 -9
  35. package/mcp-app.js +7 -9
  36. package/mcp-modern.d.ts +14 -16
  37. package/mcp-modern.js +23 -27
  38. package/metrics.js +0 -1
  39. package/node-server.d.ts +9 -9
  40. package/node-server.js +25 -34
  41. package/package-version.js +2 -4
  42. package/package.json +1 -1
  43. package/rate-limit.d.ts +6 -1
  44. package/rate-limit.js +24 -8
  45. package/rest-api.d.ts +13 -14
  46. package/rest-api.js +11 -12
  47. package/rules.d.ts +6 -7
  48. package/rules.js +30 -42
  49. package/self-host.d.ts +16 -2
  50. package/self-host.js +42 -21
  51. package/slotlock.manifest.json +1 -1
  52. package/store.d.ts +109 -161
  53. package/store.js +253 -371
  54. package/sync.d.ts +44 -59
  55. package/sync.js +325 -469
  56. package/timezone.d.ts +25 -33
  57. package/timezone.js +37 -52
  58. package/types.d.ts +35 -56
  59. package/webhooks.d.ts +22 -25
  60. package/webhooks.js +35 -40
  61. package/weekly-hours.d.ts +3 -0
  62. 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. Dynamic calendar data arrives only through the host's
9
- * `ui/notifications/tool-result` channel; the template contains no tenant or event data and uses
10
- * textContent for every untrusted value. Keeping this as reviewed source instead of generated UI
11
- * also lets MCP hosts inspect and cache the exact resource before rendering it.
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 (from `slotlock_find_available_slots` or `slotlock_find_next_available`) can be
14
- * picked: the view sends the host one `ui/message` naming it, in a fixed sentence holding only the
15
- * resource id and the two instants it checked, so the agent books it through the normal path,
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. Dynamic calendar data arrives only through the host's
20
- * `ui/notifications/tool-result` channel; the template contains no tenant or event data and uses
21
- * textContent for every untrusted value. Keeping this as reviewed source instead of generated UI
22
- * also lets MCP hosts inspect and cache the exact resource before rendering it.
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 (from `slotlock_find_available_slots` or `slotlock_find_next_available`) can be
25
- * picked: the view sends the host one `ui/message` naming it, in a fixed sentence holding only the
26
- * resource id and the two instants it checked, so the agent books it through the normal path,
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 HTTP headers that mirror its body, in the
20
- * specification's order: the required `_meta` fields (-32602, basic §_meta), `MCP-Protocol-Version`
21
- * against the body (-32020), a revision the server implements (-32022, basic/versioning), then
22
- * `Mcp-Method` and `Mcp-Name` against the body, the latter after Base64-sentinel decoding (-32020,
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 carry and echo: `v1.<payload>.<HMAC-SHA256>`, both base64url.
44
- * It is integrity-protected, not encrypted, so it must never hold anything the client may not see.
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 that verified it; `null`
49
- * for anything else. The MAC is checked in constant time before the payload is parsed.
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): never cached, not buffered by a reverse
73
- * proxy (`X-Accel-Buffering: no`). `onCancel` runs once when the reader goes away first — the client
74
- * closed the stream, which on HTTP is how it cancels the request.
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 the request's `_meta` (MCP reserves `traceparent`, `tracestate` and `baggage`
85
- * there) or, when `_meta` has none, from the HTTP headers of the same names. One source only, never
86
- * a mix; an invalid `traceparent` discards the context, and an invalid `tracestate` or `baggage` is
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 for the Slotlock agent server: the per-request `_meta` envelope, the
4
- * Streamable HTTP request headers, the sealed multi-round-trip `requestState`, SSE framing and W3C
5
- * trace context. Which method does what — authorization, dispatch, results — stays in
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 horizontal tab. Anything else must arrive in the
27
- // `=?base64?…?=` sentinel (Streamable HTTP §Value Encoding).
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` (or `Mcp-Param-*`) header stands for, decoded from the Base64 sentinel when
34
- * used; `null` when it is malformed: bad Base64, invalid UTF-8, or raw characters outside the
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 HTTP headers that mirror its body, in the
55
- * specification's order: the required `_meta` fields (-32602, basic §_meta), `MCP-Protocol-Version`
56
- * against the body (-32020), a revision the server implements (-32022, basic/versioning), then
57
- * `Mcp-Method` and `Mcp-Name` against the body, the latter after Base64-sentinel decoding (-32020,
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 carry and echo: `v1.<payload>.<HMAC-SHA256>`, both base64url.
143
- * It is integrity-protected, not encrypted, so it must never hold anything the client may not see.
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 that verified it; `null`
152
- * for anything else. The MAC is checked in constant time before the payload is parsed.
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 two spare bits of the last character, so three other
167
- // spellings of the same MAC would pass.
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): never cached, not buffered by a reverse
200
- * proxy (`X-Accel-Buffering: no`). `onCancel` runs once when the reader goes away first — the client
201
- * closed the stream, which on HTTP is how it cancels the request.
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 the request's `_meta` (MCP reserves `traceparent`, `tracestate` and `baggage`
270
- * there) or, when `_meta` has none, from the HTTP headers of the same names. One source only, never
271
- * a mix; an invalid `traceparent` discards the context, and an invalid `tracestate` or `baggage` is
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 (an IPv4 client of a dual-stack listener reads
8
- * `::ffff:a.b.c.d`). Behind a proxy it is the proxy's; never taken from a request field.
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 connections, to end
17
- * long-lived responses (MCP subscription streams) so they drain within the grace period.
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 used to construct the Request passed to authentication and policy callbacks.
24
- * It is explicit so an untrusted Host or forwarding header can never choose security context.
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 returned by `createSlotlockAgentServer` to a hardened Node HTTP listener.
61
- * The listener is intentionally HTTP-only; public HTTPS termination and trusted proxy policy remain
62
- * deployment concerns, while Request identity always uses the explicit canonical `requestOrigin`.
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 that a target may cancel, as it does a body over its size limit.
79
- * Cancelling a `Readable.toWeb` stream destroys the IncomingMessage: Node emits 'aborted', read
80
- * here as the client leaving, and the unread rest stays on the connection, which then holds
81
- * `close()` until it is forced. Cancelling this one only stops delivery; once the response is
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 while it waits.
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 a body it has already answered gets an error, not a read that
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: `/v1/resources/%2E%2E/availability`
139
- // would reach the target as `/v1/availability`, a path parameter of `..` naming another route.
140
- // It also takes a backslash for a slash, so a segment ends at either.
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 implementation requires `duplex: 'half'` for streamed request bodies, but the
149
- // DOM RequestInit visible to workspace consumers does not declare that Node-only extension.
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, say: nothing will read the body, so drop it as it arrives.
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 returned by `createSlotlockAgentServer` to a hardened Node HTTP listener.
216
- * The listener is intentionally HTTP-only; public HTTPS termination and trusted proxy policy remain
217
- * deployment concerns, while Request identity always uses the explicit canonical `requestOrigin`.
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
- // Read through a function: inside a request handler TypeScript would keep the narrowing of its
235
- // opening check, but `close()` can change the state while the handler awaits.
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 its connections (every 30 s by default), so a body
245
- // trickled after its answer could outlast requestTimeout by that much.
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 a body the target did not read (one it refused as
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
- // A keep-alive connection whose response finished during shutdown would otherwise hold
323
- // `server.close()` open until its idle timeout; Node closed only the idle ones at the start.
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 it was listening. Node abandons a listen whose server closes first, with
345
- // neither 'listening' nor 'error', so the pending listen() is rejected here, as an aborted
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 or failing hook must not hold shutdown past its grace period.
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
  }
@@ -1,7 +1,5 @@
1
- // The package version, read from package.json so that file is the one place it is set. Built, this
2
- // module sits beside the artifact's own package.json (scripts/prepare-package.mjs writes it from the
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slotlock",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
6
6
  "license": "Apache-2.0",
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 { SlotlockLimits, SlotlockRate } from './config.js';
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. */