slotlock 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/dashboard.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // The dashboard: people sign in with GitHub and manage the API keys and calendar resources of a
2
- // personal tenant, `github:<user id>`. It is server-rendered HTML with no inline script or style,
2
+ // tenant: their own, `github:<user id>`, or with `organizations`, a GitHub organization's,
3
+ // `github-org:<organization id>`, shared by its owners and members. It is server-rendered HTML with no inline script or style,
3
4
  // so its Content-Security-Policy allows nothing but this server's own stylesheet and script.
4
5
  //
5
- // Sign-in is GitHub's OAuth web flow with `state` and PKCE (S256) and no scope: the token is used
6
- // once, to read who signed in, and dropped. A session is an HMAC-sealed cookie (`__Host-`,
6
+ // Sign-in is GitHub's OAuth web flow with `state` and PKCE (S256), with no scope (`read:org` with
7
+ // organizations): the token is used once, to read who signed in and their memberships, and dropped. A session is an HMAC-sealed cookie (`__Host-`,
7
8
  // HttpOnly, Secure, SameSite=Lax) that lasts 12 hours and is checked against the allowlist on every
8
9
  // request. Every form carries a CSRF token bound to the session, and every POST must come from this
9
10
  // server's own origin.
@@ -13,8 +14,11 @@
13
14
  // keeps these in Postgres, so they hold on every server that shares the database; the resource cap
14
15
  // is held in the database too.
15
16
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
+ import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
16
18
  import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
17
19
  import { ignoreRejection } from './hooks.js';
20
+ import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
21
+ import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
18
22
  import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
19
23
  /** Resources one dashboard tenant may hold. */
20
24
  export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
@@ -26,6 +30,8 @@ const MAX_FORM_BYTES = 8_192;
26
30
  * This also bounds how fast unauthenticated callbacks can write sign-in records.
27
31
  */
28
32
  const MAX_SIGN_INS_IN_FLIGHT = 8;
33
+ /** Of those, how many one client address may run at once, so one client cannot take every slot. */
34
+ const MAX_SIGN_INS_IN_FLIGHT_PER_CLIENT = 2;
29
35
  /** Entries each in-memory record keeps (createSlotlockMemoryDashboardState); past this the oldest go first. */
30
36
  const MAX_REMEMBERED = 10_000;
31
37
  /** A form's single-use value: 18 random bytes, then a MAC binding them to the session. */
@@ -36,7 +42,14 @@ const POST_ROUTES = new Set([
36
42
  '/keys/rotate',
37
43
  '/keys/revoke',
38
44
  '/resources',
45
+ '/resources/rules',
46
+ '/resources/pool',
47
+ '/webhooks',
48
+ '/webhooks/delete',
49
+ '/webhooks/enable',
50
+ '/waitlist/leave',
39
51
  '/hours',
52
+ '/organization',
40
53
  ]);
41
54
  const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
42
55
  const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
@@ -75,20 +88,48 @@ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-sr
75
88
  * in the database (a READ COMMITTED transaction, under a per-tenant lock).
76
89
  */
77
90
  export function createSlotlockDashboardResources(store) {
91
+ const by = (actor) => (actor === undefined ? {} : { actor });
78
92
  return {
79
93
  list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
80
- add: (tenantRef, externalRef, timezone) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
94
+ add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
81
95
  tenantRef,
82
96
  externalRef,
83
97
  timezone,
84
98
  maxTenantResources: SLOTLOCK_DASHBOARD_MAX_RESOURCES,
85
- }), { isolation: 'read committed' }),
86
- setHours: (tenantRef, target, rules) => store.withTenant(tenantRef, async (tenant) => {
99
+ }), { isolation: 'read committed', ...by(actor) }),
100
+ setHours: (tenantRef, target, rules, actor) => store.withTenant(tenantRef, async (tenant) => {
87
101
  if (target === 'all')
88
102
  return tenant.setTenantAvailability({ tenantRef, rules });
89
103
  const updated = await tenant.setResourceAvailability({ tenantRef, id: target.id, rules });
90
104
  return updated ? 1 : 0;
91
- }),
105
+ }, by(actor)),
106
+ agenda: (tenantRef, window, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAgenda({ tenantRef, window, limit })),
107
+ recentChanges: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listRecentChanges({ tenantRef, limit })),
108
+ setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
109
+ setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
110
+ waitlist: {
111
+ list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
112
+ async leave(tenantRef, id, actor) {
113
+ const left = await store.withTenant(tenantRef, (tenant) => tenant.leaveWaitlist({ tenantRef, id }), by(actor));
114
+ // An offered entry's hold went with it: offer that time to the next, as the API does.
115
+ if (left) {
116
+ await store
117
+ .withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }))
118
+ .catch(() => undefined);
119
+ }
120
+ return left;
121
+ },
122
+ },
123
+ webhooks: {
124
+ list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listWebhookEndpoints({ tenantRef })),
125
+ create: (tenantRef, url, eventTypes, actor) => store.withTenant(tenantRef, (tenant) => tenant.createWebhookEndpoint({
126
+ tenantRef,
127
+ url,
128
+ ...(eventTypes !== undefined ? { eventTypes } : {}),
129
+ }), by(actor)),
130
+ remove: async (tenantRef, id, actor) => (await store.withTenant(tenantRef, (tenant) => tenant.deleteWebhookEndpoint({ tenantRef, id }), by(actor))).deleted,
131
+ enable: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.enableWebhookEndpoint({ tenantRef, id }), by(actor)),
132
+ },
92
133
  };
93
134
  }
94
135
  function assertExpiry(expiresAt) {
@@ -100,6 +141,28 @@ function formLimitError() {
100
141
  return Object.assign(new Error(`Slotlock dashboard: ${SLOTLOCK_DASHBOARD_FORM_LIMIT} forms are live for this person`), { code: 'dashboard_form_limit' });
101
142
  }
102
143
  const ownerDigest = (owner) => createHash('sha256').update(`slotlock-dashboard-owner.${owner}`).digest();
144
+ /**
145
+ * Records accepted write confirmations (`SlotlockAgentConfirmationOptions.consumeAcceptance`) in
146
+ * Postgres, shared by every server on the database: true the first time a confirmation's nonce is
147
+ * recorded, false while it stays recorded, so an accepted confirmation runs its write once on any
148
+ * server. Kept until the confirmation's own expiry, through the same function and table as the
149
+ * dashboard's records (only a SHA-256 digest of the nonce is stored); pass a client, not a
150
+ * transaction.
151
+ */
152
+ export function createSlotlockAcceptanceRecords(sql) {
153
+ if (!('begin' in sql)) {
154
+ throw new Error('createSlotlockAcceptanceRecords needs a client, not a transaction: a rollback would undo its records');
155
+ }
156
+ const client = sql;
157
+ return async ({ nonce, expiresAt }) => {
158
+ const digest = createHash('sha256').update(`slotlock-confirmation.${nonce}`).digest();
159
+ return (await client.begin('isolation level read committed', async (tx) => {
160
+ const [row] = await tx `
161
+ SELECT slotlock.use_dashboard_token('confirmation', ${digest}, ${expiresAt}, NULL::bytea) AS used`;
162
+ return row?.used === true;
163
+ }));
164
+ };
165
+ }
103
166
  /**
104
167
  * The dashboard's records in Postgres, shared by every server on the database. Run `applySchema()`
105
168
  * and `grantApplicationRole()` first: the serving role reaches the records only through
@@ -187,6 +250,9 @@ export function createSlotlockMemoryDashboardState(now = Date.now) {
187
250
  },
188
251
  };
189
252
  }
253
+ /** Served organizations one session lists at most: the session is a cookie of at most 4 KB. */
254
+ const MAX_SESSION_ORGANIZATIONS = 20;
255
+ const ORGANIZATION_ID = /^[1-9]\d{0,19}$/;
190
256
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
191
257
  function escapeHtml(value) {
192
258
  return value
@@ -217,6 +283,100 @@ function html(strings, ...values) {
217
283
  });
218
284
  return new Html(out);
219
285
  }
286
+ /** Days of bookings the overview shows, how many it reads and lists, and its activity's length. */
287
+ const AGENDA_DAYS = 7;
288
+ const AGENDA_LIMIT = 200;
289
+ const AGENDA_SHOWN = 30;
290
+ const ACTIVITY_LIMIT = 20;
291
+ const WAITLIST_SHOWN = 200;
292
+ const RESOURCE_PAGE = /^\/resources\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
293
+ /** How the booking rules form names each rule. */
294
+ const RULE_LIMITS = SLOTLOCK_BOOKING_RULE_LIMITS;
295
+ const RULE_NAMES = Object.freeze({
296
+ minNoticeMinutes: 'Minimum notice',
297
+ maxAdvanceDays: 'Book up to',
298
+ slotStepMinutes: 'Start step',
299
+ turnaroundMinutes: 'Turnaround',
300
+ checkInMinutes: 'Check-in release',
301
+ });
302
+ /** The booking rules form's fields, by rule. */
303
+ const RULE_FIELDS = Object.freeze({
304
+ minNoticeMinutes: 'min_notice_minutes',
305
+ maxAdvanceDays: 'max_advance_days',
306
+ slotStepMinutes: 'slot_step_minutes',
307
+ turnaroundMinutes: 'turnaround_minutes',
308
+ checkInMinutes: 'check_in_minutes',
309
+ });
310
+ const icon = (paths) => new Html(`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`);
311
+ const ICONS = Object.freeze({
312
+ mark: new Html('<svg class="mark" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path d="M8 46 L26 14 L56 44"></path></svg>'),
313
+ overview: icon('<path d="M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-3H4zM14 7h6V4h-6z"/>'),
314
+ resources: icon('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
315
+ keys: icon('<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3"/>'),
316
+ waitlist: icon('<path d="M4 6h16M4 12h10M4 18h6"/><circle cx="18" cy="16" r="3"/>'),
317
+ webhooks: icon('<path d="M9 7a3 3 0 1 1 4 2.8L16 15M6.5 13.5A3 3 0 1 0 9 18h6M15 18a3 3 0 1 0 1.5-5.6"/>'),
318
+ settings: icon('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
319
+ check: icon('<path d="M5 12l5 5L20 7"/>'),
320
+ github: new Html('<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="fill" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>'),
321
+ });
322
+ /** How the overview names each change the feed records. */
323
+ const CHANGE_LABELS = Object.freeze({
324
+ 'hold.created': { text: 'Hold placed', tone: 'neutral' },
325
+ 'hold.updated': { text: 'Hold changed', tone: 'neutral' },
326
+ 'hold.confirmed': { text: 'Hold booked', tone: 'good' },
327
+ 'hold.released': { text: 'Hold released', tone: 'neutral' },
328
+ 'hold.expired': { text: 'Hold expired', tone: 'neutral' },
329
+ 'reservation.created': { text: 'Booked', tone: 'good' },
330
+ 'reservation.updated': { text: 'Booking moved', tone: 'neutral' },
331
+ 'reservation.cancelled': { text: 'Booking cancelled', tone: 'bad' },
332
+ 'reservation.checked_in': { text: 'Checked in', tone: 'good' },
333
+ 'reservation.no_show': { text: 'No-show released', tone: 'bad' },
334
+ 'event.created': { text: 'Event added', tone: 'good' },
335
+ 'event.updated': { text: 'Event changed', tone: 'neutral' },
336
+ 'event.cancelled': { text: 'Event cancelled', tone: 'bad' },
337
+ 'event.timezone_shifted': { text: 'Event moved by a time zone change', tone: 'neutral' },
338
+ 'event.timezone_conflict': { text: 'Event clashes after a time zone change', tone: 'bad' },
339
+ 'coverage.stale': { text: 'Calendar sync is behind', tone: 'bad' },
340
+ 'waitlist.offered': { text: 'Waitlist offer sent', tone: 'good' },
341
+ 'waitlist.expired': { text: 'Waitlist offer lapsed', tone: 'neutral' },
342
+ 'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
343
+ });
344
+ const zoned = (zone, parts) => {
345
+ try {
346
+ return new Intl.DateTimeFormat('en-GB', { ...parts, timeZone: zone });
347
+ }
348
+ catch {
349
+ return new Intl.DateTimeFormat('en-GB', { ...parts, timeZone: 'UTC' });
350
+ }
351
+ };
352
+ /** `14:30` in the resource's own time zone. */
353
+ function clock(date, zone) {
354
+ return zoned(zone, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(date);
355
+ }
356
+ /** The day a time falls on in a zone: `Today`, `Tomorrow`, or `Thu 30 Oct`, with a key to group by. */
357
+ function dayLabel(date, zone, nowMs) {
358
+ const key = (at) => zoned(zone, { year: 'numeric', month: '2-digit', day: '2-digit' }).format(at);
359
+ const day = key(date);
360
+ if (day === key(new Date(nowMs)))
361
+ return { key: day, label: 'Today' };
362
+ if (day === key(new Date(nowMs + DAY_MS)))
363
+ return { key: day, label: 'Tomorrow' };
364
+ return {
365
+ key: day,
366
+ label: zoned(zone, { weekday: 'short', day: 'numeric', month: 'short' }).format(date),
367
+ };
368
+ }
369
+ /** `just now`, `5 min ago`, `3 h ago`, `2 d ago`. */
370
+ function ago(date, nowMs) {
371
+ const minutes = Math.max(0, Math.floor((nowMs - date.getTime()) / 60_000));
372
+ if (minutes < 1)
373
+ return 'just now';
374
+ if (minutes < 60)
375
+ return `${minutes} min ago`;
376
+ if (minutes < 1_440)
377
+ return `${Math.floor(minutes / 60)} h ago`;
378
+ return `${Math.floor(minutes / 1_440)} d ago`;
379
+ }
220
380
  function safeEqual(a, b) {
221
381
  const left = createHash('sha256').update(a).digest();
222
382
  const right = createHash('sha256').update(b).digest();
@@ -422,6 +582,16 @@ export function createSlotlockDashboard(options) {
422
582
  const authorizeUrl = options.github.authorizeUrl ?? 'https://github.com/login/oauth/authorize';
423
583
  const tokenUrl = options.github.tokenUrl ?? 'https://github.com/login/oauth/access_token';
424
584
  const userUrl = options.github.userUrl ?? 'https://api.github.com/user';
585
+ const membershipsUrl = options.github.membershipsUrl ??
586
+ 'https://api.github.com/user/memberships/orgs?state=active&per_page=100';
587
+ const served = options.organizations ? new Set(options.organizations) : null;
588
+ if (served && (served.size === 0 || [...served].some((id) => !ORGANIZATION_ID.test(id)))) {
589
+ throw new Error('Slotlock dashboard organizations must be GitHub organization ids');
590
+ }
591
+ const brandName = options.name ?? 'Slotlock';
592
+ if (!SLOTLOCK_DASHBOARD_NAME_PATTERN.test(brandName) || brandName.trim() !== brandName) {
593
+ throw new Error('Slotlock dashboard name must be 1-60 characters, without <, > or control or invisible characters');
594
+ }
425
595
  const fetchImpl = options.fetch ?? fetch;
426
596
  const now = options.now ?? Date.now;
427
597
  if (Buffer.byteLength(options.sessionSecret, 'utf8') < 32) {
@@ -432,6 +602,8 @@ export function createSlotlockDashboard(options) {
432
602
  const allowed = (uid) => options.allowedUsers === '*' ||
433
603
  (Array.isArray(options.allowedUsers) && options.allowedUsers.includes(uid));
434
604
  let signInsInFlight = 0;
605
+ /** Sign-ins in flight by client address. */
606
+ const signInsByClient = new Map();
435
607
  function report(message) {
436
608
  try {
437
609
  ignoreRejection(options.onError?.(new Error(message)));
@@ -440,18 +612,26 @@ export function createSlotlockDashboard(options) {
440
612
  // A failing reporter must not change the response.
441
613
  }
442
614
  }
443
- const formMac = (session, nonce) => createHmac('sha256', secret)
444
- .update(`slotlock-dashboard-form-v1.${session.sid}.${nonce}`)
615
+ // Bound to the organization too: a page opened before a switch cannot write into the new one.
616
+ const formMac = (session, nonce, org = session.org ?? '') => createHmac('sha256', secret)
617
+ .update(`slotlock-dashboard-form-v2.${session.sid}.${org}.${nonce}`)
445
618
  .digest('base64url');
446
619
  /** Random, and bound to the session by a MAC: a made-up or another session's value is refused unwritten. */
447
620
  const formNonce = (session) => {
448
621
  const nonce = randomBytes(18).toString('base64url');
449
622
  return `${nonce}.${formMac(session, nonce)}`;
450
623
  };
451
- const isFormNonce = (session, once) => {
624
+ /** `ok`, `other_org` for a form from a page of another of the session's organizations, else `bad`. */
625
+ const checkFormNonce = (session, once) => {
452
626
  const match = FORM_NONCE.exec(once);
627
+ if (match === null)
628
+ return 'bad';
629
+ const [, nonce, mac] = match;
453
630
  // Compared as text, like the cookie MACs: decoding would drop the last character's spare bits.
454
- return match !== null && safeEqual(match[2], formMac(session, match[1]));
631
+ if (safeEqual(mac, formMac(session, nonce)))
632
+ return 'ok';
633
+ const other = session.orgs?.some((org) => org.id !== session.org && safeEqual(mac, formMac(session, nonce, org.id)));
634
+ return other ? 'other_org' : 'bad';
455
635
  };
456
636
  function seal(context, payload) {
457
637
  const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
@@ -489,15 +669,30 @@ export function createSlotlockDashboard(options) {
489
669
  typeof payload.login !== 'string' ||
490
670
  typeof payload.sid !== 'string' ||
491
671
  !allowed(payload.uid) ||
672
+ !organizationsHold(payload) ||
492
673
  (await options.state.sessionEnded(payload.sid, payload.exp))) {
493
674
  return null;
494
675
  }
495
676
  return payload;
496
677
  }
678
+ /**
679
+ * With organizations, the session names one the server still serves, from its own list; without
680
+ * them, it names none. A session from the other mode is signed out.
681
+ */
682
+ function organizationsHold(payload) {
683
+ if (!served)
684
+ return payload.org === undefined && payload.orgs === undefined;
685
+ if (typeof payload.org !== 'string' || !served.has(payload.org) || !Array.isArray(payload.orgs)) {
686
+ return false;
687
+ }
688
+ return payload.orgs.some((org) => isRecord(org) && org.id === payload.org && typeof org.login === 'string');
689
+ }
497
690
  const csrfToken = (session) => createHmac('sha256', secret)
498
691
  .update(`slotlock-dashboard-csrf-v1.${session.sid}`)
499
692
  .digest('base64url');
500
- const tenantOf = (session) => `github:${session.uid}`;
693
+ const tenantOf = (session) => served ? `github-org:${session.org}` : `github:${session.uid}`;
694
+ /** Who did something, for the record: the person, whatever tenant they act in. */
695
+ const personOf = (session) => `github:${session.uid}`;
501
696
  function respond(status, body, headers = {}, cookies = []) {
502
697
  const response = new Response(String(body), {
503
698
  status,
@@ -517,34 +712,75 @@ export function createSlotlockDashboard(options) {
517
712
  return response;
518
713
  }
519
714
  const redirect = (status, location, cookies = []) => respond(status, '', { Location: location }, cookies);
520
- function layout(title, content, session) {
521
- const account = session
522
- ? html `<div class="account"><span class="login">@${session.login}</span>
523
- <form method="post" action="${root}/sign-out"><input type="hidden" name="csrf" value="${csrfToken(session)}"><button class="link" type="submit">Sign out</button></form></div>`
524
- : html ``;
525
- return html `<!doctype html>
715
+ // Pages for what the adapter offers, so a custom one without a waitlist shows none. Read per
716
+ // request: on Workers the adapter exists only inside one.
717
+ const nav = () => [
718
+ { section: 'overview', label: 'Overview', path: '', icon: ICONS.overview },
719
+ { section: 'resources', label: 'Resources', path: '/resources', icon: ICONS.resources },
720
+ ...(options.resources.waitlist
721
+ ? [{ section: 'waitlist', label: 'Waitlist', path: '/waitlist', icon: ICONS.waitlist }]
722
+ : []),
723
+ { section: 'keys', label: 'API keys', path: '/keys', icon: ICONS.keys },
724
+ ...(options.resources.webhooks
725
+ ? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
726
+ : []),
727
+ { section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
728
+ ];
729
+ /** The workspace the session acts in: its organization, or the person themselves. */
730
+ function workspace(session) {
731
+ const current = session.orgs?.find((org) => org.id === session.org);
732
+ const choices = (session.orgs ?? []).filter((org) => served?.has(org.id));
733
+ const name = current?.login ?? session.login;
734
+ const badge = html `<span class="avatar" aria-hidden="true">${name.slice(0, 1).toUpperCase()}</span>`;
735
+ if (current && choices.length > 1) {
736
+ return html `<form class="workspace" method="post" action="${root}/organization">${badge}<input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><label class="visually-hidden" for="organization">Organization</label><select id="organization" name="organization">${choices.map((org) => html `<option value="${org.id}"${org.id === current.id ? html ` selected` : html ``}>${org.login}</option>`)}</select><button class="quiet" type="submit">Switch</button></form>`;
737
+ }
738
+ return html `<div class="workspace">${badge}<span class="workspace-name">${current ? name : `@${name}`}</span></div>`;
739
+ }
740
+ function layout(title, content, session, section) {
741
+ const head = html `<!doctype html>
526
742
  <html lang="en">
527
743
  <head>
528
744
  <meta charset="utf-8">
529
745
  <meta name="viewport" content="width=device-width, initial-scale=1">
530
746
  <meta name="referrer" content="${REFERRER_POLICY}">
531
- <title>${title} · Slotlock</title>
747
+ <title>${title} · ${brandName}</title>
532
748
  <link rel="stylesheet" href="${root}/style.css?v=${ASSET_VERSIONS.css}">
533
749
  <script src="${root}/app.js?v=${ASSET_VERSIONS.js}" defer></script>
534
- </head>
750
+ </head>`;
751
+ const brand = html `<a class="brand" href="${root}">${ICONS.mark}<span class="name">${brandName}</span></a>`;
752
+ if (!session) {
753
+ return html `${head}
754
+ <body class="bare">
755
+ <header class="bare-bar">${brand}</header>
756
+ <main class="bare-main">${content}</main>
757
+ </body>
758
+ </html>`;
759
+ }
760
+ const links = nav().map((item) => html `<a href="${root}${item.path}"${item.section === section ? html ` aria-current="page"` : html ``}>${item.icon}<span>${item.label}</span></a>`);
761
+ return html `${head}
535
762
  <body>
536
- <header class="bar"><a class="brand" href="${root}"><svg class="mark" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path d="M8 46 L26 14 L56 44"></path></svg><span class="name">Slotlock</span> <span class="by">by Pylota</span></a>${account}</header>
537
- <main>${content}</main>
763
+ <div class="app">
764
+ <aside class="side">
765
+ <div class="side-top">${brand}${workspace(session)}</div>
766
+ <nav class="nav" aria-label="Dashboard">${links}</nav>
767
+ <div class="me"><span class="login">@${session.login}</span><form method="post" action="${root}/sign-out"><input type="hidden" name="csrf" value="${csrfToken(session)}"><button class="quiet" type="submit">Sign out</button></form></div>
768
+ </aside>
769
+ <main class="page">${content}</main>
770
+ </div>
538
771
  </body>
539
772
  </html>`;
540
773
  }
774
+ const noticeOf = (notice) => notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``;
541
775
  function signedOutPage(status, notice, cookies = []) {
542
- const content = html `<section class="hero">
543
- <h1>Your agents' calendar keys</h1>
544
- <p>Sign in with GitHub to create API keys for the Slotlock tools, add the resources your agents book, and connect an MCP or A2A client.</p>
545
- ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
546
- <a class="button primary" href="${root}/sign-in">Sign in with GitHub</a>
547
- <p class="fine">Slotlock reads only your public GitHub profile, to know who you are, and keeps nothing from GitHub but your user id and login.</p>
776
+ const content = html `<section class="sign-in">
777
+ <h1>Sign in to ${brandName}</h1>
778
+ <p>Create keys for your agents, add what they book, and watch what they do.</p>
779
+ ${noticeOf(notice)}
780
+ <a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a>
781
+ <p class="fine">${served
782
+ ? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and the organizations it serves.`
783
+ : `${brandName} reads only your public GitHub profile, and keeps only your user id and login.`}</p>
548
784
  </section>`;
549
785
  return respond(status, layout('Sign in', content, null), {}, cookies);
550
786
  }
@@ -553,39 +789,132 @@ ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
553
789
  const expired = key.expiresAt !== null && key.expiresAt.getTime() <= now();
554
790
  const status = key.revokedAt ? 'Revoked' : expired ? 'Expired' : 'Active';
555
791
  const actions = status === 'Active'
556
- ? html `<details class="act"><summary>Rotate</summary><p>A new secret replaces this one at once. The key keeps its name, access and bookings.</p>
557
- <form method="post" action="${root}/keys/rotate"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}"><button type="submit">Rotate key</button></form></details>
558
- <details class="act danger"><summary>Revoke</summary><p>Requests with this key fail at once. What it booked stays on the calendar.</p>
559
- <form method="post" action="${root}/keys/revoke"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}"><button class="danger" type="submit">Revoke key</button></form></details>`
792
+ ? html `<details class="act"><summary>Rotate</summary><div class="pop"><p>A new secret replaces this one at once. The key keeps its name, access and bookings.</p>
793
+ <form method="post" action="${root}/keys/rotate"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}"><button type="submit">Rotate key</button></form></div></details>
794
+ <details class="act danger"><summary>Revoke</summary><div class="pop"><p>Requests with this key fail at once. What it booked stays on the calendar.</p>
795
+ <form method="post" action="${root}/keys/revoke"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}"><button class="danger" type="submit">Revoke key</button></form></div></details>`
560
796
  : html ``;
561
797
  return html `<tr>
562
- <td data-label="Name">${key.name}</td>
798
+ <td data-label="Name" class="strong">${key.name}</td>
563
799
  <td data-label="Key"><code>${key.prefix}…</code></td>
564
800
  <td data-label="Access">${key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only'}</td>
565
801
  <td data-label="Created">${formatDate(key.createdAt)}</td>
802
+ ${served ? html `<td data-label="Created by">${key.createdBy === personOf(session) ? 'You' : key.createdBy?.startsWith('github:') ? `GitHub user ${key.createdBy.slice(7)}` : 'Unknown'}</td>` : html ``}
566
803
  <td data-label="Last used">${key.lastUsedAt ? formatDate(key.lastUsedAt) : 'Never'}</td>
567
804
  <td data-label="Expires">${key.expiresAt ? formatDate(key.expiresAt) : 'Never'}</td>
568
- <td data-label="Status"><span class="status ${status.toLowerCase()}">${status}</span></td>
805
+ <td data-label="Status"><span class="pill ${status.toLowerCase()}">${status}</span></td>
569
806
  <td class="actions">${actions}</td>
570
807
  </tr>`;
571
808
  });
572
809
  }
573
- async function dashboardPage(session, status = 200, notice) {
810
+ const pageHead = (title, action) => html `<header class="page-head"><h1>${title}</h1>${action ?? html ``}</header>`;
811
+ /** A resource as people name it: its reference, else the start of its id. */
812
+ const resourceName = (resource, id) => resource?.externalRef ?? `${id.slice(0, 8)}…`;
813
+ function agendaPanel(entries, byId, more) {
814
+ if (entries.length === 0) {
815
+ return html `<p class="empty">Nothing booked in the next ${String(AGENDA_DAYS)} days.</p>`;
816
+ }
817
+ const zones = new Set(entries.map((entry) => byId.get(entry.resourceId)?.timezone ?? 'UTC'));
818
+ const groups = new Map();
819
+ for (const entry of entries.slice(0, AGENDA_SHOWN)) {
820
+ const resource = byId.get(entry.resourceId);
821
+ const zone = resource?.timezone ?? 'UTC';
822
+ const day = dayLabel(entry.start, zone, now());
823
+ const group = groups.get(day.key) ?? { label: day.label, rows: [] };
824
+ const name = resourceName(resource, entry.resourceId);
825
+ // An event by its summary; a booking or hold by what it occupies, and a booking's own
826
+ // reference when it is one a person chose (not the id Slotlock gave it).
827
+ const title = entry.kind === 'event' ? (entry.summary ?? name) : name;
828
+ const meta = [
829
+ entry.kind === 'event' ? name : null,
830
+ entry.reference && !UUID_PATTERN.test(entry.reference) ? entry.reference : null,
831
+ zones.size > 1 ? zone : null,
832
+ entry.source && !entry.source.startsWith('calendar-event:') ? entry.source : null,
833
+ ].filter((part) => part !== null);
834
+ const pill = entry.kind === 'hold' && entry.expiresAt
835
+ ? html `<span class="pill held">Held until ${clock(entry.expiresAt, zone)}</span>`
836
+ : html `<span class="pill ${entry.kind}">${entry.kind === 'event' ? 'Event' : 'Booked'}</span>`;
837
+ group.rows.push(html `<li class="slot"><span class="when">${clock(entry.start, zone)}<span class="until">${clock(entry.end, zone)}</span></span><span class="what"><span class="title">${title}</span>${meta.length > 0 ? html `<span class="meta">${meta.join(' · ')}</span>` : html ``}</span>${pill}</li>`);
838
+ groups.set(day.key, group);
839
+ }
840
+ return html `${[...groups.values()].map((group) => html `<h3 class="day">${group.label}</h3><ul class="slots">${group.rows}</ul>`)}${more ? html `<p class="fine">Showing the first ${String(AGENDA_SHOWN)}.</p>` : html ``}`;
841
+ }
842
+ function activityPanel(changes, byId, keys, session) {
843
+ if (changes.length === 0) {
844
+ return html `<p class="empty">No activity yet. What agents book, move and cancel shows here.</p>`;
845
+ }
846
+ const keyNames = new Map(keys.map((key) => [`api_key:${key.id}`, key.name]));
847
+ return html `<ol class="feed">${changes.map((change) => {
848
+ const label = CHANGE_LABELS[change.type] ?? { text: change.type, tone: 'neutral' };
849
+ const who = change.actor === null
850
+ ? brandName
851
+ : change.actor === personOf(session)
852
+ ? 'You'
853
+ : (keyNames.get(change.actor) ??
854
+ (change.actor.startsWith('api_key:')
855
+ ? 'A deleted key'
856
+ : /^github:\d+$/.test(change.actor)
857
+ ? `GitHub user ${change.actor.slice(7)}`
858
+ : change.actor.slice(0, 60)));
859
+ const where = change.resourceId
860
+ ? resourceName(byId.get(change.resourceId), change.resourceId)
861
+ : null;
862
+ return html `<li class="event"><span class="dot ${label.tone}" aria-hidden="true"></span><span class="what"><span class="title">${label.text}${where ? html ` <span class="on">on</span> ${where}` : html ``}</span><span class="meta">${who} · <time datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></span></span></li>`;
863
+ })}</ol>`;
864
+ }
865
+ async function overviewPage(session, status = 200, notice) {
574
866
  const tenantRef = tenantOf(session);
575
- const [keys, resources] = await Promise.all([
867
+ const start = new Date(now());
868
+ const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
869
+ const [keys, resources, agenda, changes] = await Promise.all([
576
870
  options.keys.list({ tenantRef }),
577
871
  options.resources.list(tenantRef),
872
+ options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
873
+ options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
578
874
  ]);
875
+ const byId = new Map(resources.map((resource) => [resource.id, resource]));
876
+ const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
877
+ const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
878
+ const booked = agenda?.filter((entry) => entry.kind !== 'hold').length ?? 0;
879
+ const held = agenda?.filter((entry) => entry.kind === 'hold').length ?? 0;
880
+ const stat = (label, value, href) => html `<a class="stat" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
881
+ const steps = [
882
+ { done: resources.length > 0, text: 'Add what your agents book', href: `${root}/resources` },
883
+ { done: active.length > 0, text: 'Create a key for an agent', href: `${root}/keys` },
884
+ { done: (changes?.length ?? 0) > 0, text: 'Connect the agent and book', href: `${root}/keys` },
885
+ ];
886
+ const content = html `${noticeOf(notice)}
887
+ ${pageHead('Overview')}
888
+ ${steps.every((step) => step.done)
889
+ ? html ``
890
+ : html `<section class="panel setup"><h2>Get started</h2><ol class="steps">${steps.map((step) => html `<li class="${step.done ? 'done' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span>${step.done ? html `<span>${step.text}</span>` : html `<a href="${step.href}">${step.text}</a>`}${step.done ? html `<span class="visually-hidden">(done)</span>` : html ``}</li>`)}</ol></section>`}
891
+ <div class="stats">
892
+ ${agenda ? stat(`Booked, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`) : html ``}
893
+ ${agenda ? stat('Live holds', counted(held), `${root}#upcoming`) : html ``}
894
+ ${stat('Resources', String(resources.length), `${root}/resources`)}
895
+ ${stat('Active keys', String(active.length), `${root}/keys`)}
896
+ </div>
897
+ <div class="split">
898
+ ${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
899
+ ${changes ? html `<section class="panel"><h2>Agent activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
900
+ </div>`;
901
+ return respond(status, layout('Overview', content, session, 'overview'));
902
+ }
903
+ function endpoints() {
904
+ return html `<dl class="endpoints"><dt>MCP</dt><dd><code id="mcp-url">${mcpUrl}</code><button class="quiet" type="button" data-copy="mcp-url">Copy</button></dd><dt>A2A</dt><dd><code id="a2a-url">${a2aUrl}</code><button class="quiet" type="button" data-copy="a2a-url">Copy</button></dd></dl>`;
905
+ }
906
+ async function keysPage(session, status = 200, notice) {
907
+ const keys = await options.keys.list({ tenantRef: tenantOf(session) });
579
908
  const token = csrfToken(session);
580
- const rules = options.availability ?? [];
581
- const content = html `${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
582
- <section class="card">
909
+ const content = html `${noticeOf(notice)}
910
+ ${pageHead('API keys')}
911
+ <section class="panel">
583
912
  <h2>Connect an agent</h2>
584
- <p>Agents call Slotlock over MCP or A2A with one of your keys: <code>Authorization: Bearer slk_…</code>.</p>
585
- <dl class="endpoints"><dt>MCP</dt><dd><code>${mcpUrl}</code></dd><dt>A2A</dt><dd><code>${a2aUrl}</code></dd></dl>
913
+ <p>Agents call ${brandName} over MCP or A2A with a key: <code>Authorization: Bearer slk_…</code></p>
914
+ ${endpoints()}
586
915
  </section>
587
- <section>
588
- <div class="head"><div><h2>API keys</h2><p>Each key acts in your calendar only. Give every agent its own, with the least access it needs.</p></div></div>
916
+ <section class="panel">
917
+ <h2>New key</h2>
589
918
  <form class="create" method="post" action="${root}/keys">
590
919
  <input type="hidden" name="csrf" value="${token}">
591
920
  <input type="hidden" name="once" value="${formNonce(session)}">
@@ -594,33 +923,338 @@ ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
594
923
  <label>Expires<select name="expires"><option value="90">In 90 days</option><option value="30">In 30 days</option><option value="365">In a year</option><option value="never">Never</option></select></label>
595
924
  <button class="primary" type="submit">Create key</button>
596
925
  </form>
597
- ${keys.length === 0
598
- ? html `<p class="empty">No keys yet. Create one to connect an agent.</p>`
599
- : html `<div class="table"><table><thead><tr><th>Name</th><th>Key</th><th>Access</th><th>Created</th><th>Last used</th><th>Expires</th><th>Status</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
600
- <tbody>${keyRows(keys, token, session)}</tbody></table></div>`}
601
926
  </section>
602
- <section>
603
- <div class="head"><div><h2>Resources</h2><p>What your agents book: a car, a room, a person, a machine. Up to ${SLOTLOCK_DASHBOARD_MAX_RESOURCES}.</p></div></div>
927
+ ${keys.length === 0
928
+ ? html `<p class="empty">No keys yet. Give every agent its own, with the least access it needs.</p>`
929
+ : served
930
+ ? html `<p class="fine">Each key acts in this organization only, and every member can rotate or revoke it.</p><div class="table"><table><thead><tr><th>Name</th><th>Key</th><th>Access</th><th>Created</th><th>Created by</th><th>Last used</th><th>Expires</th><th>Status</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
931
+ <tbody>${keyRows(keys, token, session)}</tbody></table></div>`
932
+ : html `<div class="table"><table><thead><tr><th>Name</th><th>Key</th><th>Access</th><th>Created</th><th>Last used</th><th>Expires</th><th>Status</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
933
+ <tbody>${keyRows(keys, token, session)}</tbody></table></div>`}`;
934
+ return respond(status, layout('API keys', content, session, 'keys'));
935
+ }
936
+ async function resourcesPage(session, status = 200, notice) {
937
+ const resources = await options.resources.list(tenantOf(session));
938
+ const token = csrfToken(session);
939
+ const content = html `${noticeOf(notice)}
940
+ ${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : undefined)}
941
+ <section class="panel">
942
+ <h2>Add a resource</h2>
604
943
  <form class="create" method="post" action="${root}/resources">
605
944
  <input type="hidden" name="csrf" value="${token}">
606
945
  <input type="hidden" name="once" value="${formNonce(session)}">
607
946
  <label>Reference<input name="reference" required maxlength="200" placeholder="vehicle-42"></label>
608
947
  <label>Time zone<input name="timezone" required maxlength="64" value="UTC" placeholder="Europe/London"></label>
609
- <button type="submit">Add resource</button>
948
+ <button class="primary" type="submit">Add resource</button>
610
949
  </form>
950
+ <p class="fine">${String(resources.length)} of ${String(SLOTLOCK_DASHBOARD_MAX_RESOURCES)}. Adding a reference you have changes its time zone.</p>
951
+ </section>
611
952
  ${resources.length === 0
612
953
  ? html `<p class="empty">No resources yet. Agents can only book what is listed here.</p>`
613
- : html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference">${resource.externalRef ?? ''}</td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours">${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></td><td data-label="Resource id"><code>${resource.id}</code></td></tr>`)}</tbody></table></div>`}
954
+ : html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a href="${root}/resources/${resource.id}">${resourceName(resource, resource.id)}</a>${resource.pool ? html `<span class="meta">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours">${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></td><td data-label="Resource id"><code class="muted">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
955
+ return respond(status, layout('Resources', content, session, 'resources'));
956
+ }
957
+ function settingsPage(session) {
958
+ const rules = options.availability ?? [];
959
+ const current = session.orgs?.find((org) => org.id === session.org);
960
+ const content = html `${pageHead('Settings')}
961
+ <section class="panel">
962
+ <h2>Endpoints</h2>
963
+ ${endpoints()}
614
964
  </section>
615
- <section>
616
- <h2>Bookable hours</h2>
965
+ <section class="panel">
966
+ <h2>Server hours</h2>
617
967
  ${rules.length === 0
618
- ? html `<p class="empty">This server offers no bookable hours: agents find a free slot only on resources with hours of their own.</p>`
968
+ ? html `<p class="empty">None: agents find a free slot only on resources with hours of their own.</p>`
619
969
  : html `<ul class="hours">${rules.map((rule) => html `<li>${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}</li>`)}</ul>`}
620
- <p class="fine">The server's hours, for resources without hours of their own, in each resource's own time zone.</p>
621
- ${resources.length > 0 ? html `<p><a href="${root}/hours">Set hours for every resource</a></p>` : html ``}
970
+ <p class="fine">For resources without hours of their own, in each resource's time zone. Set by the server's operator.</p>
971
+ </section>
972
+ <section class="panel">
973
+ <h2>Account</h2>
974
+ <dl class="facts"><dt>Signed in as</dt><dd>@${session.login}</dd>${current
975
+ ? html `<dt>Organization</dt><dd>${current.login}</dd>${(session.orgs?.length ?? 0) > 1 ? html `<dt>Also a member of</dt><dd>${(session.orgs ?? []).filter((org) => org.id !== current.id).map((org) => org.login).join(', ')}</dd>` : html ``}`
976
+ : html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
977
+ ${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
978
+ </section>`;
979
+ return respond(200, layout('Settings', content, session, 'settings'));
980
+ }
981
+ /** The tenant's resource with that id and every resource (for names), or null for anything else. */
982
+ async function resourceDetailPage(session, id, status = 200, notice) {
983
+ const tenantRef = tenantOf(session);
984
+ const resource = await ownResource(session, id);
985
+ if (!resource)
986
+ return notFound(session);
987
+ const start = new Date(now());
988
+ const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
989
+ const [agenda, changes, keys] = await Promise.all([
990
+ options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
991
+ options.resources.recentChanges?.(tenantRef, 100),
992
+ options.keys.list({ tenantRef }),
993
+ ]);
994
+ const byId = new Map([[resource.id, resource]]);
995
+ const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
996
+ const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
997
+ const token = csrfToken(session);
998
+ const rules = resource.bookingRules ?? {};
999
+ const field = (name, label, unit) => html `<label>${label}<span class="unit-input"><input type="number" inputmode="numeric" min="${String(RULE_LIMITS[name][0])}" max="${String(RULE_LIMITS[name][1])}" name="${RULE_FIELDS[name]}" value="${rules[name] === undefined ? '' : String(rules[name])}"><span>${unit}</span></span></label>`;
1000
+ const content = html `${noticeOf(notice)}
1001
+ <p><a class="back" href="${root}/resources">Resources</a></p>
1002
+ ${pageHead(resourceName(resource, resource.id))}
1003
+ <dl class="facts"><dt>Time zone</dt><dd>${resource.timezone}</dd><dt>Bookable hours</dt><dd>${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></dd><dt>Resource id</dt><dd><code class="muted">${resource.id}</code></dd></dl>
1004
+ <div class="split">
1005
+ <div class="stack">
1006
+ ${options.resources.setRules
1007
+ ? html `<section class="panel">
1008
+ <h2>Booking rules</h2>
1009
+ <form method="post" action="${root}/resources/rules">
1010
+ <input type="hidden" name="csrf" value="${token}">
1011
+ <input type="hidden" name="once" value="${formNonce(session)}">
1012
+ <input type="hidden" name="id" value="${resource.id}">
1013
+ <div class="grid-form">
1014
+ ${field('minNoticeMinutes', 'Minimum notice', 'min')}
1015
+ ${field('maxAdvanceDays', 'Book up to', 'days ahead')}
1016
+ ${field('slotStepMinutes', 'Start on a step of', 'min')}
1017
+ ${field('turnaroundMinutes', 'Turnaround after each booking', 'min')}
1018
+ ${field('checkInMinutes', 'Release if not checked in after', 'min')}
1019
+ </div>
1020
+ <label class="choice"><input type="checkbox" name="enforce_hours" value="1"${rules.enforceHours ? html ` checked` : html ``}> Only inside bookable hours</label>
1021
+ <label class="choice"><input type="checkbox" name="require_certain" value="1"${rules.requireCertain ? html ` checked` : html ``}> Only in time its calendar syncs have read today</label>
1022
+ <p class="fine">Leave a field empty for no rule.</p>
1023
+ <button class="primary" type="submit">Save rules</button>
1024
+ </form>
1025
+ </section>`
1026
+ : html ``}
1027
+ ${options.resources.setPool
1028
+ ? html `<section class="panel">
1029
+ <h2>Pool</h2>
1030
+ <form class="create" method="post" action="${root}/resources/pool">
1031
+ <input type="hidden" name="csrf" value="${token}">
1032
+ <input type="hidden" name="once" value="${formNonce(session)}">
1033
+ <input type="hidden" name="id" value="${resource.id}">
1034
+ <label>Pool name<input name="pool" maxlength="100" value="${resource.pool ?? ''}" placeholder="suv"></label>
1035
+ <button type="submit">Save pool</button>
1036
+ </form>
1037
+ <p class="fine">Agents can book any free member of a pool. Leave it empty for none.</p>
1038
+ </section>`
1039
+ : html ``}
1040
+ </div>
1041
+ <div class="stack">
1042
+ ${mine ? html `<section class="panel"><h2>Upcoming</h2>${agendaPanel(mine, byId, mine.length > AGENDA_SHOWN)}</section>` : html ``}
1043
+ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activity, byId, keys, session)}</section>` : html ``}
1044
+ </div>
1045
+ </div>`;
1046
+ return respond(status, layout(resourceName(resource, resource.id), content, session, 'resources'));
1047
+ }
1048
+ async function saveRules(session, form) {
1049
+ const id = form.get('id') ?? '';
1050
+ const resource = await ownResource(session, id);
1051
+ if (!resource || !options.resources.setRules)
1052
+ return notFound(session);
1053
+ const rules = {};
1054
+ for (const [name, key] of Object.entries(RULE_FIELDS)) {
1055
+ const value = (form.get(key) ?? '').trim();
1056
+ if (value === '')
1057
+ continue;
1058
+ const [min, max] = RULE_LIMITS[name];
1059
+ if (!/^\d{1,7}$/.test(value) || Number(value) < min || Number(value) > max) {
1060
+ return resourceDetailPage(session, id, 400, `${RULE_NAMES[name]} is a whole number from ${min.toLocaleString('en')} to ${max.toLocaleString('en')}. Leave it empty for none.`);
1061
+ }
1062
+ rules[name] = Number(value);
1063
+ }
1064
+ if (form.get('enforce_hours') === '1')
1065
+ rules.enforceHours = true;
1066
+ if (form.get('require_certain') === '1')
1067
+ rules.requireCertain = true;
1068
+ try {
1069
+ await options.resources.setRules(tenantOf(session), id, Object.keys(rules).length === 0 ? null : rules, personOf(session));
1070
+ }
1071
+ catch (error) {
1072
+ if (error.code !== 'invalid_booking_rules')
1073
+ throw error;
1074
+ return resourceDetailPage(session, id, 400, 'Those rules are not ones Slotlock can keep.');
1075
+ }
1076
+ return redirect(303, `${root}/resources/${id}`);
1077
+ }
1078
+ async function savePool(session, form) {
1079
+ const id = form.get('id') ?? '';
1080
+ const resource = await ownResource(session, id);
1081
+ if (!resource || !options.resources.setPool)
1082
+ return notFound(session);
1083
+ const pool = (form.get('pool') ?? '').trim();
1084
+ try {
1085
+ await options.resources.setPool(tenantOf(session), id, pool === '' ? null : pool, personOf(session));
1086
+ }
1087
+ catch (error) {
1088
+ if (error.code !== 'invalid_pool')
1089
+ throw error;
1090
+ return resourceDetailPage(session, id, 400, 'A pool name is 1 to 100 letters, digits, dots, dashes or underscores.');
1091
+ }
1092
+ return redirect(303, `${root}/resources/${id}`);
1093
+ }
1094
+ function endpointState(endpoint) {
1095
+ if (endpoint.disabledAt)
1096
+ return { text: 'Disabled', tone: 'revoked' };
1097
+ if (endpoint.failingSince)
1098
+ return { text: 'Failing', tone: 'held' };
1099
+ return { text: 'Delivering', tone: 'active' };
1100
+ }
1101
+ async function webhooksPage(session, status = 200, notice) {
1102
+ const hooks = options.resources.webhooks;
1103
+ if (!hooks)
1104
+ return notFound(session);
1105
+ const endpoints = await hooks.list(tenantOf(session));
1106
+ const token = csrfToken(session);
1107
+ const failing = endpoints.filter((endpoint) => endpoint.failingSince && !endpoint.disabledAt);
1108
+ const disabled = endpoints.filter((endpoint) => endpoint.disabledAt);
1109
+ const rows = endpoints.map((endpoint) => {
1110
+ const state = endpointState(endpoint);
1111
+ const problem = endpoint.disabledAt
1112
+ ? `${endpoint.disabledReason ?? 'Disabled'} (${formatDate(endpoint.disabledAt)})`
1113
+ : endpoint.failingSince
1114
+ ? `Since ${formatDate(endpoint.failingSince)}, ${String(endpoint.attempts)} attempts${endpoint.lastStatus ? `, last HTTP ${endpoint.lastStatus}` : ''}${endpoint.lastError ? `: ${endpoint.lastError.slice(0, 120)}` : ''}`
1115
+ : '';
1116
+ return html `<tr>
1117
+ <td data-label="URL" class="strong wrap"><code>${endpoint.url}</code></td>
1118
+ <td data-label="Events">${endpoint.eventTypes ? endpoint.eventTypes.join(', ') : 'All'}</td>
1119
+ <td data-label="Status"><span class="cell"><span class="pill ${state.tone}">${state.text}</span>${problem ? html `<span class="meta wrap">${problem}</span>` : html ``}</span></td>
1120
+ <td data-label="Added">${formatDate(endpoint.createdAt)}</td>
1121
+ <td class="actions">${endpoint.disabledAt
1122
+ ? html `<form class="inline" method="post" action="${root}/webhooks/enable"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${endpoint.id}"><button type="submit">Enable</button></form>`
1123
+ : html ``}<details class="act danger"><summary>Delete</summary><div class="pop"><p>Nothing more is sent to it. The feed keeps its changes.</p>
1124
+ <form method="post" action="${root}/webhooks/delete"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${endpoint.id}"><button class="danger" type="submit">Delete webhook</button></form></div></details></td>
1125
+ </tr>`;
1126
+ });
1127
+ const content = html `${noticeOf(notice)}
1128
+ ${pageHead('Webhooks')}
1129
+ <div class="stats">
1130
+ <div class="stat"><span class="stat-label">Endpoints</span><span class="stat-value">${String(endpoints.length)}</span></div>
1131
+ <div class="stat"><span class="stat-label">Failing</span><span class="stat-value">${String(failing.length)}</span></div>
1132
+ <div class="stat"><span class="stat-label">Disabled</span><span class="stat-value">${String(disabled.length)}</span></div>
1133
+ </div>
1134
+ <section class="panel">
1135
+ <h2>Add an endpoint</h2>
1136
+ <form method="post" action="${root}/webhooks">
1137
+ <input type="hidden" name="csrf" value="${token}">
1138
+ <input type="hidden" name="once" value="${formNonce(session)}">
1139
+ <label>URL<input name="url" type="url" required maxlength="2048" placeholder="https://example.com/slotlock"></label>
1140
+ <details class="types"><summary>Events: all, or choose</summary><div class="checks">${SLOTLOCK_CHANGE_TYPES.map((type) => html `<label class="choice"><input type="checkbox" name="event_type" value="${type}"> <code>${type}</code></label>`)}</div></details>
1141
+ <button class="primary" type="submit">Add endpoint</button>
1142
+ </form>
1143
+ <p class="fine">Signed as Standard Webhooks with a secret shown once. An endpoint failing for a day is disabled.</p>
1144
+ </section>
1145
+ ${endpoints.length === 0
1146
+ ? html `<p class="empty">No webhooks. Add one to hear about bookings as they change.</p>`
1147
+ : html `<div class="table"><table><thead><tr><th>URL</th><th>Events</th><th>Status</th><th>Added</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`}`;
1148
+ return respond(status, layout('Webhooks', content, session, 'webhooks'));
1149
+ }
1150
+ async function addWebhook(session, form) {
1151
+ const hooks = options.resources.webhooks;
1152
+ if (!hooks)
1153
+ return notFound(session);
1154
+ const types = form.getAll('event_type');
1155
+ const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
1156
+ if (!created.ok) {
1157
+ const message = created.code === 'webhook_limit_reached'
1158
+ ? `You have ${String(created.limit)} webhooks, as many as Slotlock allows. Delete one first.`
1159
+ : created.code === 'invalid_webhook_url'
1160
+ ? 'Give an https URL, with no user name, password or #fragment.'
1161
+ : 'Choose events from the list.';
1162
+ return webhooksPage(session, created.code === 'webhook_limit_reached' ? 409 : 400, message);
1163
+ }
1164
+ const content = html `<section class="card once">
1165
+ <h1>Webhook added</h1>
1166
+ <p><code>${created.endpoint.url}</code></p>
1167
+ <p class="notice warn" role="alert">Copy the signing secret now. It will not be shown again.</p>
1168
+ <div class="copy"><input id="secret" readonly value="${created.secret}" aria-label="Signing secret"><button type="button" data-copy="secret">Copy</button></div>
1169
+ <p>Verify each delivery's <code>webhook-signature</code> with it, using a Standard Webhooks library.</p>
1170
+ <a class="button primary" href="${root}/webhooks">Done</a>
622
1171
  </section>`;
623
- return respond(status, layout('Dashboard', content, session));
1172
+ return respond(200, layout('Webhook added', content, session, 'webhooks'));
1173
+ }
1174
+ async function changeWebhook(session, form, change) {
1175
+ const hooks = options.resources.webhooks;
1176
+ if (!hooks)
1177
+ return notFound(session);
1178
+ const id = form.get('id') ?? '';
1179
+ if (change === 'remove') {
1180
+ if (!(await hooks.remove(tenantOf(session), id, personOf(session)))) {
1181
+ return webhooksPage(session, 404, 'That webhook is not one of yours.');
1182
+ }
1183
+ return redirect(303, `${root}/webhooks`);
1184
+ }
1185
+ const enabled = await hooks.enable(tenantOf(session), id, personOf(session));
1186
+ if (enabled === null)
1187
+ return webhooksPage(session, 404, 'That webhook is not one of yours.');
1188
+ if ('code' in enabled) {
1189
+ return webhooksPage(session, 409, 'It missed changes older than the feed keeps. Delete it, add it again, and read what it missed from the feed.');
1190
+ }
1191
+ return redirect(303, `${root}/webhooks`);
1192
+ }
1193
+ async function waitlistPage(session, status = 200, notice) {
1194
+ const waitlist = options.resources.waitlist;
1195
+ if (!waitlist)
1196
+ return notFound(session);
1197
+ const tenantRef = tenantOf(session);
1198
+ const [entries, resources] = await Promise.all([
1199
+ waitlist.list(tenantRef, WAITLIST_SHOWN),
1200
+ options.resources.list(tenantRef),
1201
+ ]);
1202
+ const byId = new Map(resources.map((resource) => [resource.id, resource]));
1203
+ const token = csrfToken(session);
1204
+ const rows = entries.map((entry) => {
1205
+ const resource = entry.resourceId ? byId.get(entry.resourceId) : undefined;
1206
+ const zone = resource?.timezone ?? 'UTC';
1207
+ const day = dayLabel(entry.start, zone, now());
1208
+ const what = entry.resourceId
1209
+ ? resourceName(resource, entry.resourceId)
1210
+ : `Pool ${entry.pool ?? ''}`;
1211
+ const offered = entry.offeredResourceId
1212
+ ? resourceName(byId.get(entry.offeredResourceId), entry.offeredResourceId)
1213
+ : null;
1214
+ const tone = entry.status === 'offered' ? 'active' : entry.status === 'expired' ? 'revoked' : 'held';
1215
+ return html `<tr>
1216
+ <td data-label="When" class="strong">${day.label} ${clock(entry.start, zone)}–${clock(entry.end, zone)}</td>
1217
+ <td data-label="For">${what}</td>
1218
+ <td data-label="Holder"><code>${entry.holderRef}</code></td>
1219
+ <td data-label="Status"><span class="cell"><span class="pill ${tone}">${entry.status === 'offered' ? 'Offered' : entry.status === 'expired' ? 'Expired' : 'Waiting'}</span>${offered && entry.offeredAt ? html `<span class="meta">${offered}, ${ago(entry.offeredAt, now())}</span>` : html ``}</span></td>
1220
+ <td data-label="Joined">${ago(entry.createdAt, now())}</td>
1221
+ <td class="actions"><details class="act danger"><summary>Remove</summary><div class="pop"><p>${entry.status === 'offered' ? 'Its offered hold is released and offered to the next.' : 'It stops waiting.'}</p>
1222
+ <form method="post" action="${root}/waitlist/leave"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${entry.id}"><button class="danger" type="submit">Remove entry</button></form></div></details></td>
1223
+ </tr>`;
1224
+ });
1225
+ const waiting = entries.filter((entry) => entry.status === 'waiting').length;
1226
+ const offered = entries.filter((entry) => entry.status === 'offered').length;
1227
+ const content = html `${noticeOf(notice)}
1228
+ ${pageHead('Waitlist')}
1229
+ <div class="stats">
1230
+ <div class="stat"><span class="stat-label">Waiting</span><span class="stat-value">${String(waiting)}</span></div>
1231
+ <div class="stat"><span class="stat-label">Offered</span><span class="stat-value">${String(offered)}</span></div>
1232
+ </div>
1233
+ ${entries.length === 0
1234
+ ? html `<p class="empty">Nobody is waiting. Agents add entries when the time they want is taken.</p>`
1235
+ : html `<div class="table"><table><thead><tr><th>When</th><th>For</th><th>Holder</th><th>Status</th><th>Joined</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>${entries.length >= WAITLIST_SHOWN ? html `<p class="fine">Showing the first ${String(WAITLIST_SHOWN)}.</p>` : html ``}`}`;
1236
+ return respond(status, layout('Waitlist', content, session, 'waitlist'));
1237
+ }
1238
+ async function leaveWaitlist(session, form) {
1239
+ const waitlist = options.resources.waitlist;
1240
+ if (!waitlist)
1241
+ return notFound(session);
1242
+ if (!(await waitlist.leave(tenantOf(session), form.get('id') ?? '', personOf(session)))) {
1243
+ return waitlistPage(session, 404, 'That entry is not on your waitlist.');
1244
+ }
1245
+ return redirect(303, `${root}/waitlist`);
1246
+ }
1247
+ /** The page a form was sent from, to show what went wrong with it. */
1248
+ function pageFor(route) {
1249
+ if (route.startsWith('/keys'))
1250
+ return keysPage;
1251
+ if (route.startsWith('/webhooks') && options.resources.webhooks)
1252
+ return webhooksPage;
1253
+ if (route.startsWith('/waitlist') && options.resources.waitlist)
1254
+ return waitlistPage;
1255
+ if (route.startsWith('/resources') || route === '/hours')
1256
+ return resourcesPage;
1257
+ return overviewPage;
624
1258
  }
625
1259
  function hoursPage(session, state) {
626
1260
  const { target, mode } = state;
@@ -642,7 +1276,7 @@ ${resources.length > 0 ? html `<p><a href="${root}/hours">Set hours for every re
642
1276
  });
643
1277
  const content = html `${state.notice ? html `<p class="notice" role="alert">${state.notice}</p>` : html ``}
644
1278
  <section>
645
- <p><a href="${root}">Back to the dashboard</a></p>
1279
+ <p><a class="back" href="${root}/resources">Resources</a></p>
646
1280
  <h1>${title}</h1>
647
1281
  <p>${target === 'all'
648
1282
  ? 'The same hours for every resource, each in its own time zone. Saving replaces their hours.'
@@ -664,7 +1298,7 @@ ${state.unshown
664
1298
  <button class="primary" type="submit">Save hours</button>
665
1299
  </form>
666
1300
  </section>`;
667
- return respond(state.status ?? 200, layout('Bookable hours', content, session));
1301
+ return respond(state.status ?? 200, layout('Bookable hours', content, session, 'resources'));
668
1302
  }
669
1303
  /** The tenant's resource with that id, or null for anything else. */
670
1304
  async function ownResource(session, id) {
@@ -737,26 +1371,39 @@ ${state.unshown
737
1371
  });
738
1372
  }
739
1373
  }
740
- const changed = await options.resources.setHours(tenantOf(session), target === 'all' ? 'all' : { id: target.id }, rules);
1374
+ const changed = await options.resources.setHours(tenantOf(session), target === 'all' ? 'all' : { id: target.id }, rules, personOf(session));
741
1375
  if (changed === 0) {
742
1376
  return target === 'all'
743
- ? dashboardPage(session, 409, 'Add a resource first, then set its hours.')
1377
+ ? resourcesPage(session, 409, 'Add a resource first, then set its hours.')
744
1378
  : notFound(session);
745
1379
  }
746
- return redirect(303, root);
1380
+ return redirect(303, `${root}/resources`);
747
1381
  }
748
1382
  function shownOncePage(session, key, apiKey, rotated) {
1383
+ const org = session.orgs?.find((candidate) => candidate.id === session.org);
749
1384
  const content = html `<section class="card once">
750
1385
  <h1>${rotated ? 'Key rotated' : 'Key created'}: ${apiKey.name}</h1>
1386
+ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html ``}
751
1387
  <p class="notice warn" role="alert">Copy this key now. Slotlock keeps only its fingerprint, so it will not be shown again.${rotated ? ' The old key no longer works.' : ''}</p>
752
1388
  <div class="copy"><input id="key" readonly value="${key}" aria-label="API key"><button type="button" data-copy="key">Copy</button></div>
753
1389
  <h2>Add it to an MCP client</h2>
754
1390
  <pre><code id="command">claude mcp add --transport http slotlock ${mcpUrl} \\
755
1391
  --header "Authorization: Bearer ${key}"</code></pre>
756
1392
  <p>Keep the key in a secret manager. Rotate it if it leaks; revoke it when the agent is retired.</p>
757
- <a class="button primary" href="${root}">Done</a>
1393
+ <a class="button primary" href="${root}/keys">Done</a>
758
1394
  </section>`;
759
- return respond(200, layout('Your new key', content, session));
1395
+ return respond(200, layout('Your new key', content, session, 'keys'));
1396
+ }
1397
+ /** Act in another of the session's organizations: the same session, sealed again. */
1398
+ function switchOrganization(session, form) {
1399
+ const wanted = form.get('organization') ?? '';
1400
+ const org = session.orgs?.find((candidate) => candidate.id === wanted && served?.has(candidate.id));
1401
+ if (!served || !org) {
1402
+ return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization.</p>`, session));
1403
+ }
1404
+ const sealed = seal('slotlock-dashboard-session-v1', { ...session, org: org.id });
1405
+ const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
1406
+ return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
760
1407
  }
761
1408
  async function startSignIn() {
762
1409
  const state = randomBytes(32).toString('base64url');
@@ -769,6 +1416,8 @@ ${state.unshown
769
1416
  state,
770
1417
  code_challenge: challenge,
771
1418
  code_challenge_method: 'S256',
1419
+ // Reading organization membership needs read:org; a personal tenant needs no scope at all.
1420
+ ...(served ? { scope: 'read:org' } : {}),
772
1421
  }).toString();
773
1422
  const sealed = seal('slotlock-dashboard-sign-in-v1', {
774
1423
  state,
@@ -835,10 +1484,62 @@ ${state.unshown
835
1484
  }
836
1485
  return { uid: String(body.id), login: body.login };
837
1486
  }
1487
+ /**
1488
+ * The served organizations the token's person is an active member of, by login, or null when
1489
+ * GitHub does not answer. Only `state: active` counts; at most MAX_SESSION_ORGANIZATIONS.
1490
+ */
1491
+ async function readMemberships(token) {
1492
+ const response = await fetchImpl(membershipsUrl, {
1493
+ headers: {
1494
+ Authorization: `Bearer ${token}`,
1495
+ Accept: 'application/vnd.github+json',
1496
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
1497
+ 'User-Agent': USER_AGENT,
1498
+ },
1499
+ redirect: 'manual',
1500
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
1501
+ });
1502
+ if (!response.ok) {
1503
+ report(`GitHub membership lookup failed: HTTP ${response.status}`);
1504
+ return null;
1505
+ }
1506
+ const body = await response.json().catch(() => null);
1507
+ if (!Array.isArray(body))
1508
+ return null;
1509
+ const found = new Map();
1510
+ for (const membership of body) {
1511
+ // Owners and members only: a billing manager is not a member, and may invite more of them.
1512
+ if (!isRecord(membership) ||
1513
+ membership.state !== 'active' ||
1514
+ (membership.role !== 'admin' && membership.role !== 'member')) {
1515
+ continue;
1516
+ }
1517
+ const organization = membership.organization;
1518
+ if (!isRecord(organization) ||
1519
+ !Number.isSafeInteger(organization.id) ||
1520
+ organization.id <= 0 ||
1521
+ typeof organization.login !== 'string' ||
1522
+ !GITHUB_LOGIN.test(organization.login)) {
1523
+ continue;
1524
+ }
1525
+ const id = String(organization.id);
1526
+ if (served?.has(id))
1527
+ found.set(id, { id, login: organization.login });
1528
+ }
1529
+ return [...found.values()]
1530
+ .sort((a, b) => a.login.localeCompare(b.login))
1531
+ .slice(0, MAX_SESSION_ORGANIZATIONS);
1532
+ }
838
1533
  async function askGitHub(code, verifier) {
839
1534
  try {
840
1535
  const token = await exchangeCode(code, verifier);
841
- return token === null ? null : await readGitHubUser(token);
1536
+ if (token === null)
1537
+ return null;
1538
+ const user = await readGitHubUser(token);
1539
+ if (!user || !served)
1540
+ return user;
1541
+ const orgs = await readMemberships(token);
1542
+ return orgs === null ? null : { ...user, orgs };
842
1543
  }
843
1544
  catch (error) {
844
1545
  report(`GitHub sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`);
@@ -861,11 +1562,16 @@ ${state.unshown
861
1562
  !safeEqual(state, pending.state)) {
862
1563
  return signedOutPage(400, SIGN_IN_AGAIN, [clearSignIn]);
863
1564
  }
864
- if (signInsInFlight >= MAX_SIGN_INS_IN_FLIGHT) {
1565
+ const client = options.clientAddress?.(request);
1566
+ const fromClient = client === undefined ? 0 : (signInsByClient.get(client) ?? 0);
1567
+ if (signInsInFlight >= MAX_SIGN_INS_IN_FLIGHT ||
1568
+ fromClient >= MAX_SIGN_INS_IN_FLIGHT_PER_CLIENT) {
865
1569
  return signedOutPage(503, 'Too many people are signing in at once. Sign in again in a moment.', [clearSignIn]);
866
1570
  }
867
1571
  const verifier = pending.verifier;
868
1572
  signInsInFlight += 1;
1573
+ if (client !== undefined)
1574
+ signInsByClient.set(client, fromClient + 1);
869
1575
  let user;
870
1576
  try {
871
1577
  // Recorded before GitHub is asked, so a replayed callback is refused on every server unasked.
@@ -876,6 +1582,13 @@ ${state.unshown
876
1582
  }
877
1583
  finally {
878
1584
  signInsInFlight -= 1;
1585
+ if (client !== undefined) {
1586
+ const left = (signInsByClient.get(client) ?? 1) - 1;
1587
+ if (left === 0)
1588
+ signInsByClient.delete(client);
1589
+ else
1590
+ signInsByClient.set(client, left);
1591
+ }
879
1592
  }
880
1593
  if (!user) {
881
1594
  return signedOutPage(502, 'GitHub did not complete the sign-in. Try again.', [clearSignIn]);
@@ -885,11 +1598,16 @@ ${state.unshown
885
1598
  clearSignIn,
886
1599
  ]);
887
1600
  }
1601
+ const orgs = user.orgs ?? [];
1602
+ if (served && orgs.length === 0) {
1603
+ return signedOutPage(403, `@${user.login} is not a member of an organization this Slotlock server serves.`, [clearSignIn]);
1604
+ }
888
1605
  const session = seal('slotlock-dashboard-session-v1', {
889
1606
  uid: user.uid,
890
1607
  login: user.login,
891
1608
  sid: randomBytes(24).toString('base64url'),
892
1609
  exp: now() + SESSION_TTL_MS,
1610
+ ...(served ? { orgs, org: orgs[0].id } : {}),
893
1611
  });
894
1612
  return redirect(303, root, [
895
1613
  clearSignIn,
@@ -926,7 +1644,7 @@ ${state.unshown
926
1644
  const scopes = KEY_ACCESS[form.get('access') ?? ''];
927
1645
  const expiry = form.get('expires') ?? '';
928
1646
  if (!scopes || !Object.hasOwn(KEY_EXPIRY_DAYS, expiry)) {
929
- return dashboardPage(session, 400, 'Choose an access level and an expiry from the lists.');
1647
+ return keysPage(session, 400, 'Choose an access level and an expiry from the lists.');
930
1648
  }
931
1649
  const days = KEY_EXPIRY_DAYS[expiry];
932
1650
  try {
@@ -934,7 +1652,7 @@ ${state.unshown
934
1652
  tenantRef: tenantOf(session),
935
1653
  name: form.get('name') ?? '',
936
1654
  scopes,
937
- createdBy: tenantOf(session),
1655
+ createdBy: personOf(session),
938
1656
  ...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
939
1657
  });
940
1658
  return shownOncePage(session, created.key, created.apiKey, false);
@@ -942,10 +1660,10 @@ ${state.unshown
942
1660
  catch (error) {
943
1661
  const code = error.code;
944
1662
  if (code === 'invalid_api_key_name') {
945
- return dashboardPage(session, 400, 'Give the key a name of 1 to 100 characters.');
1663
+ return keysPage(session, 400, 'Give the key a name of 1 to 100 characters.');
946
1664
  }
947
1665
  if (code === 'api_key_limit_reached') {
948
- return dashboardPage(session, 409, 'You have as many keys as Slotlock allows. Revoke one first.');
1666
+ return keysPage(session, 409, 'You have as many keys as Slotlock allows. Revoke one first.');
949
1667
  }
950
1668
  throw error;
951
1669
  }
@@ -953,41 +1671,41 @@ ${state.unshown
953
1671
  async function changeKey(session, form, change) {
954
1672
  const id = form.get('id') ?? '';
955
1673
  if (!UUID_PATTERN.test(id))
956
- return dashboardPage(session, 404, 'That key is not one of yours.');
1674
+ return keysPage(session, 404, 'That key is not one of yours.');
957
1675
  if (change === 'rotate') {
958
1676
  const rotated = await options.keys.rotate({ tenantRef: tenantOf(session), id });
959
1677
  if (!rotated)
960
- return dashboardPage(session, 404, 'That key is not an active key of yours.');
1678
+ return keysPage(session, 404, 'That key is not an active key of yours.');
961
1679
  return shownOncePage(session, rotated.key, rotated.apiKey, true);
962
1680
  }
963
1681
  const revoked = await options.keys.revoke({ tenantRef: tenantOf(session), id });
964
1682
  if (!revoked)
965
- return dashboardPage(session, 404, 'That key is not one of yours.');
966
- return redirect(303, root);
1683
+ return keysPage(session, 404, 'That key is not one of yours.');
1684
+ return redirect(303, `${root}/keys`);
967
1685
  }
968
1686
  async function addResource(session, form) {
969
1687
  const reference = (form.get('reference') ?? '').trim();
970
1688
  const timezone = (form.get('timezone') ?? '').trim();
971
1689
  if (reference.length === 0 || reference.length > 200) {
972
- return dashboardPage(session, 400, 'Give the resource a reference of 1 to 200 characters.');
1690
+ return resourcesPage(session, 400, 'Give the resource a reference of 1 to 200 characters.');
973
1691
  }
974
1692
  try {
975
- await options.resources.add(tenantOf(session), reference, timezone);
1693
+ await options.resources.add(tenantOf(session), reference, timezone, personOf(session));
976
1694
  }
977
1695
  catch (error) {
978
1696
  const code = error.code;
979
1697
  if (code === 'resource_limit_reached') {
980
- return dashboardPage(session, 409, `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
1698
+ return resourcesPage(session, 409, `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
981
1699
  }
982
1700
  if (code === 'invalid_timezone') {
983
- return dashboardPage(session, 400, `${timezone} is not a time zone Slotlock knows, such as Europe/London.`);
1701
+ return resourcesPage(session, 400, `${timezone} is not a time zone Slotlock knows, such as Europe/London.`);
984
1702
  }
985
1703
  if (code === 'invalid_external_ref') {
986
- return dashboardPage(session, 400, 'Give the resource a reference of 1 to 200 characters.');
1704
+ return resourcesPage(session, 400, 'Give the resource a reference of 1 to 200 characters.');
987
1705
  }
988
1706
  throw error;
989
1707
  }
990
- return redirect(303, root);
1708
+ return redirect(303, `${root}/resources`);
991
1709
  }
992
1710
  async function handlePost(request, route) {
993
1711
  // A browser sends Origin with every form POST; anything else did not come from these pages.
@@ -1019,19 +1737,23 @@ ${state.unshown
1019
1737
  return respond(400, layout('Refused', html `<p class="notice">That form could not be read.</p>`, session));
1020
1738
  }
1021
1739
  if (once !== null) {
1740
+ const checked = checkFormNonce(session, once);
1741
+ if (checked === 'other_org') {
1742
+ return pageFor(route)(session, 409, 'That page was for another organization. Nothing was changed: check the organization and try again.');
1743
+ }
1022
1744
  let first;
1023
1745
  try {
1024
1746
  first =
1025
- isFormNonce(session, once) &&
1747
+ checked === 'ok' &&
1026
1748
  (await options.state.use('form', once, session.exp, session.uid));
1027
1749
  }
1028
1750
  catch (error) {
1029
1751
  if (error.code !== 'dashboard_form_limit')
1030
1752
  throw error;
1031
- return dashboardPage(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
1753
+ return pageFor(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
1032
1754
  }
1033
1755
  if (!first) {
1034
- return dashboardPage(session, 409, 'That form was already sent. Reload the page to start again.');
1756
+ return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
1035
1757
  }
1036
1758
  }
1037
1759
  switch (route) {
@@ -1048,6 +1770,20 @@ ${state.unshown
1048
1770
  return addResource(session, form);
1049
1771
  case '/hours':
1050
1772
  return saveHours(session, form);
1773
+ case '/organization':
1774
+ return switchOrganization(session, form);
1775
+ case '/resources/rules':
1776
+ return saveRules(session, form);
1777
+ case '/resources/pool':
1778
+ return savePool(session, form);
1779
+ case '/webhooks':
1780
+ return addWebhook(session, form);
1781
+ case '/webhooks/delete':
1782
+ return changeWebhook(session, form, 'remove');
1783
+ case '/webhooks/enable':
1784
+ return changeWebhook(session, form, 'enable');
1785
+ case '/waitlist/leave':
1786
+ return leaveWaitlist(session, form);
1051
1787
  default:
1052
1788
  return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
1053
1789
  }
@@ -1065,7 +1801,25 @@ ${state.unshown
1065
1801
  switch (route) {
1066
1802
  case '/': {
1067
1803
  const session = await readSession(request);
1068
- return session ? await dashboardPage(session) : signedOutPage(200);
1804
+ return session ? await overviewPage(session) : signedOutPage(200);
1805
+ }
1806
+ case '/keys':
1807
+ case '/resources':
1808
+ case '/webhooks':
1809
+ case '/waitlist':
1810
+ case '/settings': {
1811
+ const session = await readSession(request);
1812
+ if (!session)
1813
+ return redirect(303, root);
1814
+ if (route === '/keys')
1815
+ return await keysPage(session);
1816
+ if (route === '/resources')
1817
+ return await resourcesPage(session);
1818
+ if (route === '/webhooks')
1819
+ return await webhooksPage(session);
1820
+ if (route === '/waitlist')
1821
+ return await waitlistPage(session);
1822
+ return settingsPage(session);
1069
1823
  }
1070
1824
  case '/style.css':
1071
1825
  return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
@@ -1079,8 +1833,16 @@ ${state.unshown
1079
1833
  const session = await readSession(request);
1080
1834
  return session ? await hoursEditor(session, url) : redirect(303, root);
1081
1835
  }
1082
- default:
1836
+ default: {
1837
+ const resourceId = RESOURCE_PAGE.exec(route)?.[1];
1838
+ if (resourceId !== undefined) {
1839
+ const session = await readSession(request);
1840
+ return session
1841
+ ? await resourceDetailPage(session, resourceId.toLowerCase())
1842
+ : redirect(303, root);
1843
+ }
1083
1844
  return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, null));
1845
+ }
1084
1846
  }
1085
1847
  }
1086
1848
  if (request.method === 'POST')
@@ -1090,6 +1852,10 @@ ${state.unshown
1090
1852
  });
1091
1853
  }
1092
1854
  catch (error) {
1855
+ // A suspended tenant may look, not change: the store refuses its writes.
1856
+ if (error?.code === 'tenant_suspended') {
1857
+ return respond(403, layout('Suspended', html `<p class="notice">This account is suspended: its data can be seen, not changed.</p>`, null));
1858
+ }
1093
1859
  try {
1094
1860
  ignoreRejection(options.onError?.(error));
1095
1861
  }
@@ -1118,82 +1884,188 @@ const DASHBOARD_JS = `document.addEventListener('click', (event) => {
1118
1884
  });
1119
1885
  `;
1120
1886
  const DASHBOARD_CSS = `:root {
1121
- --ink: #0b0d12; --ink-2: #3a3f4b; --ink-3: #6b7180; --line: #e6e8ee; --paper: #ffffff; --wash: #f6f7f9;
1122
- --accent: #2655ff; --accent-ink: #ffffff; --danger: #c62828; --warn: #8a5a00; --warn-wash: #fff6e0;
1887
+ --ink: #0f1115; --ink-2: #464b57; --ink-3: #7a808d; --line: #e7e8ec; --line-2: #f0f1f4;
1888
+ --paper: #ffffff; --wash: #f7f7f9; --side: #fafafb; --hover: #f1f2f5;
1889
+ --accent: #2655ff; --accent-wash: #eef2ff; --accent-ink: #ffffff;
1890
+ --good: #12805c; --good-wash: #e8f6f0; --bad: #c4321f; --bad-wash: #fdeeec;
1891
+ --warn: #8a5a00; --warn-wash: #fff6e0; --held: #8a5a00; --held-wash: #fff4dc;
1892
+ --shadow: 0 1px 2px rgba(15, 17, 21, 0.04), 0 1px 1px rgba(15, 17, 21, 0.03);
1123
1893
  color-scheme: light dark;
1124
1894
  }
1125
1895
  @media (prefers-color-scheme: dark) {
1126
- :root { --ink: #eef0f5; --ink-2: #c3c7d1; --ink-3: #8b91a0; --line: #2a2e38; --paper: #0e1016; --wash: #161922;
1127
- --accent: #6f8cff; --accent-ink: #0b0d12; --danger: #ff6b6b; --warn: #ffcf70; --warn-wash: #2b2410; }
1896
+ :root { --ink: #eceef3; --ink-2: #b9bdc8; --ink-3: #868c99; --line: #262932; --line-2: #1d2027;
1897
+ --paper: #0d0f13; --wash: #14171d; --side: #101217; --hover: #1b1e25;
1898
+ --accent: #7b93ff; --accent-wash: #1a2145; --accent-ink: #0d0f13;
1899
+ --good: #4cc79a; --good-wash: #0f2a20; --bad: #ff7b6b; --bad-wash: #34150f;
1900
+ --warn: #ffcf70; --warn-wash: #2b2410; --held: #ffcf70; --held-wash: #2b2410;
1901
+ --shadow: none; }
1128
1902
  }
1129
1903
  * { box-sizing: border-box; }
1130
- body { margin: 0; background: var(--paper); color: var(--ink); font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
1131
- code, pre, input[readonly] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
1132
- .bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 24px; border-bottom: 1px solid var(--line); }
1133
- .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); text-decoration: none; }
1134
- .mark { width: 20px; height: 20px; fill: none; stroke: var(--accent); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
1135
- .name { font-weight: 650; font-size: 16px; }
1136
- .by { color: var(--ink-3); font-size: 12px; }
1137
- .account { display: flex; align-items: center; gap: 12px; color: var(--ink-2); }
1138
- .account form { margin: 0; }
1139
- main { max-width: 1040px; margin: 0 auto; padding: 32px 24px 64px; display: grid; gap: 40px; }
1140
- h1 { font-size: 28px; line-height: 1.2; margin: 0 0 12px; letter-spacing: -0.01em; }
1141
- h2 { font-size: 18px; margin: 0 0 6px; }
1142
- p { margin: 0 0 12px; color: var(--ink-2); }
1143
- .fine { font-size: 13px; color: var(--ink-3); }
1144
- .hero { max-width: 560px; margin: 48px auto; text-align: center; }
1145
- .card { border: 1px solid var(--line); border-radius: 12px; padding: 20px; background: var(--wash); }
1146
- .head { display: flex; justify-content: space-between; align-items: end; gap: 16px; margin-bottom: 12px; }
1147
- .endpoints { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 12px 0 0; }
1148
- .endpoints dt { color: var(--ink-3); }
1149
- .endpoints dd { margin: 0; overflow-wrap: anywhere; }
1150
- .create { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin: 0 0 16px; }
1151
- label { display: grid; gap: 4px; font-size: 13px; color: var(--ink-2); }
1152
- input, select { font: inherit; height: 38px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); color: var(--ink); min-width: 0; }
1153
- button, .button { font: inherit; font-weight: 600; height: 38px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--paper); color: var(--ink); cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; }
1154
- .primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
1155
- button.danger { border-color: var(--danger); color: var(--danger); }
1156
- button.link { border: 0; background: none; padding: 0; height: auto; color: var(--ink-2); font-weight: 500; text-decoration: underline; }
1904
+ html { -webkit-text-size-adjust: 100%; }
1905
+ body { margin: 0; background: var(--paper); color: var(--ink); font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
1906
+ a { color: var(--accent); text-decoration: none; }
1907
+ a:hover { text-decoration: underline; }
1908
+ code, pre, input[readonly] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
1909
+ .icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
1910
+ .icon .fill { fill: currentColor; stroke: none; }
1911
+ .mark { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
1912
+ .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 650; font-size: 15px; letter-spacing: -0.01em; }
1913
+ .brand:hover { text-decoration: none; }
1914
+ .app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; background: linear-gradient(to right, var(--side) 239px, var(--line) 239px, var(--line) 240px, var(--paper) 240px); }
1915
+ .side { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; gap: 16px; padding: 16px 12px; }
1916
+ .side-top { display: grid; gap: 14px; padding: 0 6px; }
1917
+ .workspace { display: flex; align-items: center; gap: 8px; margin: 0; padding: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); min-width: 0; }
1918
+ .workspace-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1919
+ .workspace select { flex: 1; min-width: 0; height: 28px; border: 0; padding: 0 2px; font-weight: 600; background: transparent; }
1920
+ .avatar { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 6px; background: var(--accent-wash); color: var(--accent); font-size: 12px; font-weight: 700; }
1921
+ .nav { display: grid; gap: 2px; }
1922
+ .nav a { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 7px; color: var(--ink-2); font-weight: 500; }
1923
+ .nav a:hover { background: var(--hover); color: var(--ink); text-decoration: none; }
1924
+ .nav a[aria-current="page"] { background: var(--hover); color: var(--ink); font-weight: 600; }
1925
+ .nav a[aria-current="page"] .icon { color: var(--accent); }
1926
+ .me { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 10px 0; border-top: 1px solid var(--line); color: var(--ink-3); font-size: 13px; }
1927
+ .me form { margin: 0; }
1928
+ .login { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1929
+ .page { width: 100%; max-width: 1120px; padding: 28px 40px 64px; display: grid; gap: 20px; align-content: start; }
1930
+ .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1931
+ h1 { font-size: 22px; line-height: 1.25; margin: 0; letter-spacing: -0.015em; font-weight: 650; }
1932
+ h2 { font-size: 14px; margin: 0 0 12px; font-weight: 600; }
1933
+ h3 { font-size: 12px; margin: 0; font-weight: 600; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.04em; }
1934
+ p { margin: 0 0 10px; color: var(--ink-2); }
1935
+ .fine { font-size: 12.5px; color: var(--ink-3); margin: 8px 0 0; }
1936
+ .muted { color: var(--ink-3); }
1937
+ .panel, .card { border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; background: var(--paper); box-shadow: var(--shadow); min-width: 0; }
1938
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
1939
+ .stat { display: grid; gap: 4px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); box-shadow: var(--shadow); color: var(--ink); }
1940
+ .stat:hover { border-color: var(--ink-3); text-decoration: none; }
1941
+ .stat-label { font-size: 12.5px; color: var(--ink-3); }
1942
+ .stat-value { font-size: 24px; font-weight: 650; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
1943
+ .split { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 20px; align-items: start; }
1944
+ .setup .steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
1945
+ .steps li { display: flex; align-items: center; gap: 10px; color: var(--ink-2); }
1946
+ .steps li.done { color: var(--ink-3); text-decoration: line-through; }
1947
+ .check { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 999px; border: 1.5px solid var(--line); color: var(--accent-ink); }
1948
+ .done .check { background: var(--good); border-color: var(--good); }
1949
+ .check .icon { width: 12px; height: 12px; stroke-width: 3; }
1950
+ .day { margin: 14px 0 6px; }
1951
+ .day:first-of-type { margin-top: 0; }
1952
+ .slots, .feed { list-style: none; margin: 0; padding: 0; }
1953
+ .slot { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--line-2); }
1954
+ .slot:last-child { border-bottom: 0; }
1955
+ .when { display: grid; font-weight: 600; font-variant-numeric: tabular-nums; }
1956
+ .until { font-weight: 400; font-size: 12px; color: var(--ink-3); }
1957
+ .what { display: grid; min-width: 0; }
1958
+ .title { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1959
+ .on { color: var(--ink-3); font-weight: 400; }
1960
+ .meta { font-size: 12.5px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1961
+ .event { display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 12px; align-items: start; padding: 8px 0; }
1962
+ .dot { width: 8px; height: 8px; margin-top: 6px; border-radius: 999px; background: var(--ink-3); }
1963
+ .dot.good { background: var(--good); }
1964
+ .dot.bad { background: var(--bad); }
1965
+ .pill { display: inline-block; font-size: 11.5px; font-weight: 600; line-height: 18px; padding: 1px 8px; border-radius: 999px; background: var(--wash); color: var(--ink-2); white-space: nowrap; }
1966
+ .pill.booking, .pill.active { background: var(--good-wash); color: var(--good); }
1967
+ .pill.event { background: var(--accent-wash); color: var(--accent); }
1968
+ .pill.held { background: var(--held-wash); color: var(--held); }
1969
+ .pill.revoked, .pill.expired { color: var(--ink-3); }
1970
+ .endpoints { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; margin: 12px 0 0; align-items: center; }
1971
+ .endpoints dt { color: var(--ink-3); font-weight: 600; font-size: 12.5px; }
1972
+ .endpoints dd { margin: 0; display: flex; align-items: center; gap: 10px; min-width: 0; }
1973
+ .endpoints code { overflow-wrap: anywhere; }
1974
+ .facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 20px; margin: 0; }
1975
+ .facts dt { color: var(--ink-3); }
1976
+ .facts dd { margin: 0; }
1977
+ .create { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin: 0; }
1978
+ label { display: grid; gap: 5px; font-size: 12.5px; font-weight: 500; color: var(--ink-2); }
1979
+ input, select { font: inherit; height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); color: var(--ink); min-width: 0; }
1980
+ input:focus, select:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-wash); }
1981
+ button, .button { font: inherit; font-weight: 600; height: 34px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--line); background: var(--paper); color: var(--ink); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px; box-shadow: var(--shadow); }
1982
+ button:hover, .button:hover { background: var(--hover); text-decoration: none; }
1983
+ .primary, .primary:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
1984
+ .primary:hover { filter: brightness(1.06); }
1985
+ .wide { width: 100%; height: 40px; }
1986
+ button.danger { border-color: var(--bad); color: var(--bad); }
1987
+ button.quiet { height: auto; padding: 2px 6px; border: 0; background: none; box-shadow: none; color: var(--ink-3); font-weight: 500; font-size: 12.5px; }
1988
+ button.quiet:hover { color: var(--ink); background: var(--hover); }
1157
1989
  :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1158
- .table { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; }
1159
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
1160
- th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
1161
- th { font-weight: 600; color: var(--ink-3); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; background: var(--wash); }
1990
+ .table { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); box-shadow: var(--shadow); }
1991
+ table { width: 100%; border-collapse: collapse; }
1992
+ th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
1993
+ th { font-weight: 600; color: var(--ink-3); font-size: 12px; background: var(--wash); border-bottom-color: var(--line); white-space: nowrap; }
1994
+ td { white-space: nowrap; }
1995
+ td.strong { font-weight: 600; white-space: normal; min-width: 120px; }
1162
1996
  tr:last-child td { border-bottom: 0; }
1163
- .status { justify-self: start; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--wash); }
1164
- .status.active { color: var(--accent); }
1165
- .status.revoked, .status.expired { color: var(--ink-3); }
1166
- .actions { white-space: nowrap; }
1167
- .act { display: inline-block; margin-right: 8px; }
1168
- .act summary { cursor: pointer; color: var(--ink-2); }
1169
- .act.danger summary { color: var(--danger); }
1170
- .act[open] { display: block; max-width: 280px; white-space: normal; }
1171
- .act p { font-size: 13px; margin: 8px 0; }
1172
- .notice { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: var(--wash); color: var(--ink); }
1997
+ .actions { white-space: nowrap; text-align: right; }
1998
+ .act { display: inline-block; position: relative; margin-left: 8px; text-align: left; }
1999
+ .act summary { cursor: pointer; color: var(--ink-2); font-weight: 500; list-style: none; }
2000
+ .act summary::-webkit-details-marker { display: none; }
2001
+ .act.danger summary { color: var(--bad); }
2002
+ .pop { position: absolute; right: 0; z-index: 2; width: 260px; margin-top: 8px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); box-shadow: 0 8px 24px rgba(15, 17, 21, 0.12); white-space: normal; }
2003
+ .pop p { font-size: 12.5px; }
2004
+ .notice { border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; background: var(--wash); color: var(--ink); margin: 0; }
1173
2005
  .notice.warn { background: var(--warn-wash); color: var(--warn); border-color: transparent; }
1174
- .empty { color: var(--ink-3); }
2006
+ .empty { color: var(--ink-3); margin: 0; }
1175
2007
  .once { max-width: 720px; }
1176
2008
  .copy { display: flex; gap: 8px; margin: 12px 0 20px; }
1177
2009
  .copy input { flex: 1; }
1178
2010
  pre { background: var(--wash); border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
1179
- .hours { margin: 0 0 8px; padding-left: 20px; color: var(--ink-2); }
2011
+ .hours { margin: 0; padding-left: 18px; color: var(--ink-2); }
2012
+ .back::before { content: "← "; }
1180
2013
  .modes { border: 0; padding: 0; margin: 0 0 16px; display: grid; gap: 8px; }
1181
2014
  .modes legend { font-weight: 600; color: var(--ink); padding: 0; margin-bottom: 4px; }
1182
- .choice { display: flex; align-items: center; gap: 8px; font-size: 15px; color: var(--ink); }
2015
+ .choice { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--ink); }
1183
2016
  .choice input { height: auto; }
1184
2017
  .week input { width: 100%; }
1185
- .week th[scope="row"] { text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--ink); background: none; }
2018
+ .week th[scope="row"] { font-size: 13px; color: var(--ink); background: none; }
1186
2019
  form > .table { margin-bottom: 12px; }
2020
+ .bare { background: var(--wash); }
2021
+ .bare-bar { padding: 20px 24px; }
2022
+ .bare-main { display: grid; place-items: start center; padding: 8vh 16px 64px; }
2023
+ .sign-in { width: 100%; max-width: 400px; padding: 28px; border: 1px solid var(--line); border-radius: 14px; background: var(--paper); box-shadow: var(--shadow); display: grid; gap: 12px; }
2024
+ .sign-in h1 { font-size: 20px; }
2025
+ .sign-in p { margin: 0; }
2026
+ .stack { display: grid; gap: 20px; align-content: start; min-width: 0; }
2027
+ .grid-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 12px; align-items: end; }
2028
+ .cell { display: grid; justify-items: start; gap: 2px; }
2029
+ .unit-input { display: flex; align-items: center; gap: 8px; }
2030
+ .unit-input input { width: 100%; }
2031
+ .unit-input span { color: var(--ink-3); white-space: nowrap; }
2032
+ form .choice { margin: 4px 0 10px; }
2033
+ .types { margin: 12px 0; }
2034
+ .types summary { cursor: pointer; color: var(--ink-2); font-size: 13px; }
2035
+ .checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px 16px; margin-top: 10px; }
2036
+ .checks .choice { font-size: 13px; margin: 0; }
2037
+ td .meta { display: block; margin-top: 2px; }
2038
+ td[data-label] > .pill { justify-self: start; }
2039
+ td.wrap, .wrap { white-space: normal; overflow-wrap: anywhere; }
2040
+ form.inline { display: inline; margin: 0; }
2041
+ form > label { margin-bottom: 4px; }
1187
2042
  .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1188
- @media (max-width: 720px) {
1189
- .bar { padding: 12px 16px; }
1190
- main { padding: 24px 16px 48px; }
2043
+ @media (max-width: 1000px) {
2044
+ .split { grid-template-columns: minmax(0, 1fr); }
2045
+ }
2046
+ @media (max-width: 760px) {
2047
+ .app { grid-template-columns: minmax(0, 1fr); background: var(--paper); }
2048
+ .side { position: sticky; z-index: 3; height: auto; background: var(--side); border-bottom: 1px solid var(--line); flex-direction: row; flex-wrap: wrap; align-items: center; gap: 10px 12px; padding: 10px 16px 0; border-right: 0; }
2049
+ .side-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0; flex: 1 1 100%; min-width: 0; }
2050
+ .workspace { max-width: 60%; }
2051
+ .nav { display: flex; gap: 2px; overflow-x: auto; flex: 1 1 auto; margin: 0 -4px; padding-bottom: 8px; scrollbar-width: none; }
2052
+ .nav a { flex: none; padding: 6px 10px; }
2053
+ .me { margin: 0 0 8px; padding: 0; border: 0; flex: none; }
2054
+ .me .login { display: none; }
2055
+ .page { padding: 20px 16px 48px; gap: 16px; }
2056
+ .panel, .card { padding: 16px; }
2057
+ .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2058
+ .stat-value { font-size: 20px; }
1191
2059
  thead { display: none; }
1192
2060
  table, tbody, tr, td { display: block; }
1193
2061
  tr { border-bottom: 1px solid var(--line); padding: 8px 0; }
1194
- td { border: 0; padding: 3px 12px; }
1195
- td[data-label] { display: grid; grid-template-columns: 88px 1fr; gap: 8px; }
1196
- td[data-label]::before { content: attr(data-label); font-size: 12px; color: var(--ink-3); padding-top: 2px; }
2062
+ tr:last-child { border-bottom: 0; }
2063
+ td { border: 0; padding: 3px 14px; white-space: normal; }
2064
+ td[data-label] { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px; }
2065
+ td[data-label]::before { content: attr(data-label); font-size: 12px; color: var(--ink-3); }
2066
+ .actions { text-align: left; }
2067
+ .act { margin: 4px 12px 0 0; }
2068
+ .pop { left: 0; right: auto; }
1197
2069
  }
1198
2070
  `;
1199
2071
  const ASSET_VERSIONS = Object.freeze({