slotlock 0.6.3 → 0.8.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 (76) hide show
  1. package/CHANGELOG.md +511 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +70 -1
  17. package/assistant.js +478 -50
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-assistant-ui.d.ts +21 -0
  23. package/dashboard-assistant-ui.js +438 -0
  24. package/dashboard-icon.d.ts +1 -0
  25. package/dashboard-icon.js +5 -0
  26. package/dashboard-markdown.d.ts +2 -0
  27. package/dashboard-markdown.js +157 -0
  28. package/dashboard-settings-ui.d.ts +1 -0
  29. package/dashboard-settings-ui.js +78 -0
  30. package/dashboard.d.ts +227 -7
  31. package/dashboard.js +2313 -247
  32. package/ddl.d.ts +51 -12
  33. package/ddl.js +2566 -54
  34. package/event-feed.d.ts +30 -4
  35. package/event-feed.js +171 -19
  36. package/index.d.ts +15 -4
  37. package/index.js +8 -2
  38. package/maintenance.d.ts +10 -0
  39. package/maintenance.js +16 -2
  40. package/metrics.d.ts +41 -0
  41. package/metrics.js +22 -0
  42. package/openapi.json +14675 -5957
  43. package/otel.d.ts +86 -0
  44. package/otel.js +273 -0
  45. package/overage.d.ts +33 -0
  46. package/overage.js +115 -0
  47. package/package.json +1 -1
  48. package/partner-billing.d.ts +81 -0
  49. package/partner-billing.js +333 -0
  50. package/partner-names.d.ts +8 -0
  51. package/partner-names.js +10 -0
  52. package/partners.d.ts +147 -0
  53. package/partners.js +375 -0
  54. package/plans.d.ts +92 -0
  55. package/plans.js +115 -0
  56. package/quotas.d.ts +108 -0
  57. package/quotas.js +258 -0
  58. package/rate-limit.d.ts +21 -1
  59. package/rate-limit.js +82 -15
  60. package/resource-profile.d.ts +135 -0
  61. package/resource-profile.js +263 -0
  62. package/rest-api.d.ts +2 -0
  63. package/rest-api.js +68 -3
  64. package/sandbox.d.ts +70 -0
  65. package/sandbox.js +295 -0
  66. package/self-host.d.ts +33 -5
  67. package/self-host.js +950 -22
  68. package/slotlock.manifest.json +1 -1
  69. package/store.d.ts +373 -4
  70. package/store.js +1212 -118
  71. package/teams.d.ts +25 -0
  72. package/teams.js +63 -0
  73. package/timezone.js +13 -1
  74. package/types.d.ts +95 -1
  75. package/usage.d.ts +106 -0
  76. package/usage.js +238 -0
package/dashboard.js CHANGED
@@ -14,8 +14,9 @@
14
14
  // keeps these in Postgres, so they hold on every server that shares the database; the resource cap
15
15
  // is held in the database too.
16
16
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
+ import { SLOTLOCK_TEST_TENANT_SUFFIX, } from './api-keys.js';
17
18
  import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
18
- import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKey, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, } from './assistant.js';
19
+ import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_PRESETS, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKeyWithAny, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
19
20
  import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
20
21
  import { createSlotlockRateLimiter } from './rate-limit.js';
21
22
  import { createSlotlockGuardedFetch } from './webhooks.js';
@@ -23,12 +24,30 @@ import { lookup as dnsLookup } from 'node:dns/promises';
23
24
  import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
24
25
  import { ignoreRejection } from './hooks.js';
25
26
  import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
27
+ import { SLOTLOCK_DASHBOARD_ICON_SVG } from './dashboard-icon.js';
28
+ import { SLOTLOCK_SETTINGS_CSS } from './dashboard-settings-ui.js';
29
+ import { renderSlotlockMarkdown } from './dashboard-markdown.js';
30
+ import { SLOTLOCK_ASSISTANT_CSS, slotlockAssistantActionVerb, slotlockAssistantScript, slotlockAssistantStepLabel, } from './dashboard-assistant-ui.js';
31
+ import { SLOTLOCK_PLAN_LIMITS, SLOTLOCK_PLAN_QUOTAS } from './plans.js';
32
+ import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
33
+ import { slotlockQuotaPeriod } from './quotas.js';
34
+ import { slotlockUsageMonthToDate, summarizeSlotlockUsage } from './usage.js';
26
35
  import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
36
+ import { SlotlockAgentOperationError } from './agent-server.js';
37
+ import { runSlotlockApprovalGrant } from './agent-store-backend.js';
38
+ import { SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
27
39
  import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
28
40
  /** Resources one dashboard tenant may hold. */
29
- export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
41
+ export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 500;
30
42
  const SESSION_TTL_MS = 12 * 3_600_000;
31
43
  const SIGN_IN_TTL_MS = 10 * 60_000;
44
+ /**
45
+ * How recent a sign-in must be to turn an organization on or off: who owns it is read from GitHub
46
+ * as one signs in, so a change of owners reaches the dashboard by signing in again.
47
+ */
48
+ const FRESH_SIGN_IN_MS = 15 * 60_000;
49
+ /** How long Google's signing keys are kept when its answer does not say (Cache-Control max-age). */
50
+ const GOOGLE_KEYS_DEFAULT_TTL_MS = 3_600_000;
32
51
  const MAX_FORM_BYTES = 8_192;
33
52
  /** A message to the assistant: 2,000 characters, each up to 9 bytes as a form encodes it. */
34
53
  const MAX_ASSISTANT_FORM_BYTES = 24_576;
@@ -48,27 +67,48 @@ const POST_ROUTES = new Set([
48
67
  '/keys',
49
68
  '/keys/rotate',
50
69
  '/keys/revoke',
70
+ '/keys/limits',
51
71
  '/resources',
52
72
  '/resources/rules',
53
73
  '/resources/pool',
74
+ '/resources/profile',
75
+ '/resources/place',
76
+ '/resources/parent',
77
+ '/locations',
78
+ '/locations/delete',
79
+ '/groups',
80
+ '/groups/delete',
54
81
  '/webhooks',
55
82
  '/webhooks/delete',
56
83
  '/webhooks/enable',
57
84
  '/waitlist/leave',
85
+ '/approvals/decide',
86
+ '/sandbox/seed',
87
+ '/sandbox/reset',
88
+ '/sandbox/fast-forward',
58
89
  '/hours',
59
90
  '/organization',
91
+ '/environment',
92
+ '/teams/enable',
93
+ '/teams/disable',
60
94
  '/assistant',
95
+ '/assistant/stream',
61
96
  '/assistant/approve',
62
97
  '/assistant/dismiss',
63
98
  '/assistant/reset',
64
99
  '/settings/assistant',
65
100
  '/settings/assistant/test',
101
+ '/settings/assistant/models',
66
102
  '/settings/assistant/remove',
103
+ '/plan/upgrade',
104
+ '/plan/manage',
67
105
  ]);
68
106
  const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
69
107
  const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
70
108
  const MAX_COOKIE_CHARACTERS = 4_096;
71
109
  const GITHUB_TIMEOUT_MS = 10_000;
110
+ /** A Google account's subject: ASCII, at most 255 characters (OpenID Connect); Google's are digits. */
111
+ const GOOGLE_SUBJECT = /^[A-Za-z0-9._-]{1,255}$/;
72
112
  /** The REST API version GitHub documents for GET /user (docs.github.com, read 2026-10-10). */
73
113
  const GITHUB_API_VERSION = '2026-03-10';
74
114
  const USER_AGENT = 'slotlock-dashboard';
@@ -96,16 +136,85 @@ const KEY_EXPIRY_DAYS = Object.freeze({
96
136
  * still sends no referrer to any other site, so the callback URL's code never leaves.
97
137
  */
98
138
  const REFERRER_POLICY = 'same-origin';
99
- const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
139
+ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
140
+ /** The policy, with billing's origins allowed as form targets so Upgrade and Manage can redirect. */
141
+ function policyFor(billing) {
142
+ if (!billing)
143
+ return CONTENT_SECURITY_POLICY;
144
+ const contact = new URL(billing.contact);
145
+ if (contact.protocol !== 'https:' && contact.protocol !== 'mailto:') {
146
+ throw new Error('billing.contact must be an https or mailto URL');
147
+ }
148
+ const origins = billing.formOrigins.map((value) => {
149
+ const parsed = new URL(value);
150
+ if (parsed.protocol !== 'https:' || parsed.origin !== value.replace(/\/$/, '')) {
151
+ throw new Error(`billing.formOrigins must be https origins: ${value}`);
152
+ }
153
+ return parsed.origin;
154
+ });
155
+ return CONTENT_SECURITY_POLICY.replace("form-action 'self'", ["form-action 'self'", ...origins].join(' '));
156
+ }
100
157
  /**
101
158
  * The dashboard's resources, in the store under each tenant's row-level security. Adds hold the cap
102
159
  * in the database (a READ COMMITTED transaction, under a per-tenant lock).
103
160
  */
104
- export function createSlotlockDashboardResources(store) {
161
+ export function createSlotlockDashboardResources(store, options = {}) {
105
162
  // Every change here is the dashboard's, made by the person signed in (actor) when known.
106
163
  const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
164
+ const agentBackend = options.agentBackend;
107
165
  return {
166
+ sandbox: {
167
+ seed: (tenantRef, scenario) => seedSlotlockSandbox(store, tenantRef, scenario),
168
+ async reset(tenantRef) {
169
+ await resetSlotlockSandbox(store, tenantRef);
170
+ },
171
+ async fastForward(tenantRef, minutes) {
172
+ const forward = await fastForwardSlotlockSandbox(store, tenantRef, minutes);
173
+ return { holds: forward.holds, approvals: forward.approvals };
174
+ },
175
+ },
176
+ ...(agentBackend
177
+ ? {
178
+ approvals: {
179
+ list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listApprovals({ tenantRef, limit })),
180
+ async decide(tenantRef, id, decision, actor, reason) {
181
+ const decidedBy = actor ?? 'dashboard';
182
+ if (decision === 'decline') {
183
+ const declined = await store.withTenant(tenantRef, (tenant) => tenant.declineApproval({ tenantRef, id, decidedBy, ...(reason ? { reason } : {}) }), by(actor));
184
+ if (!declined.ok)
185
+ return declined;
186
+ // Its held time is free: offer it to the waitlist, as a release does.
187
+ await store
188
+ .withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), { origin: 'maintenance' })
189
+ .catch(() => undefined);
190
+ return { ok: true };
191
+ }
192
+ const approval = await store.withTenant(tenantRef, (tenant) => tenant.getApproval({ tenantRef, id }));
193
+ if (!approval)
194
+ return { ok: false, code: 'approval_not_found' };
195
+ if (approval.status !== 'pending')
196
+ return { ok: false, code: 'approval_not_pending' };
197
+ try {
198
+ await runSlotlockApprovalGrant(agentBackend, approval, {
199
+ tenantRef,
200
+ decidedBy,
201
+ signal: new AbortController().signal,
202
+ origin: 'dashboard',
203
+ });
204
+ }
205
+ catch (error) {
206
+ if (error instanceof SlotlockAgentOperationError)
207
+ return { ok: false, code: error.code };
208
+ throw error;
209
+ }
210
+ return { ok: true };
211
+ },
212
+ },
213
+ }
214
+ : {}),
108
215
  list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
216
+ count: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.countResources({ tenantRef })),
217
+ usage: (tenantRef, from, to) => store.withTenant(tenantRef, (tenant) => tenant.readUsage({ tenantRef, from, to })),
109
218
  add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
110
219
  tenantRef,
111
220
  externalRef,
@@ -123,6 +232,39 @@ export function createSlotlockDashboardResources(store) {
123
232
  setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
124
233
  ruleDefaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getBookingRuleDefaults({ tenantRef })),
125
234
  setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
235
+ setParent: (tenantRef, id, parentId, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceParent({ tenantRef, id, parentId }), by(actor)),
236
+ setProfile: (tenantRef, id, profile, actor) => store.withTenant(tenantRef, (tenant) => tenant.updateResourceProfile({ tenantRef, id, profile }), by(actor)),
237
+ grouping: {
238
+ locations: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listLocations({ tenantRef })),
239
+ putLocation: (tenantRef, location, actor) => store.withTenant(tenantRef, (tenant) => tenant.putLocation({ tenantRef, ...location }), by(actor)),
240
+ deleteLocation: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteLocation({ tenantRef, ref }), by(actor)),
241
+ groups: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listGroups({ tenantRef })),
242
+ putGroup: (tenantRef, group, actor) => store.withTenant(tenantRef, (tenant) => tenant.putGroup({ tenantRef, ...group }), by(actor)),
243
+ deleteGroup: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteGroup({ tenantRef, ref }), by(actor)),
244
+ place: (tenantRef, id, place, actor) => store.withTenant(tenantRef, async (tenant) => {
245
+ // The location first, which finds the resource (null when it is not the tenant's);
246
+ // then its groups, in the same transaction, so a refused group moves nothing.
247
+ const moved = await tenant.updateResourceProfile({
248
+ tenantRef,
249
+ id,
250
+ profile: {},
251
+ location: place.location,
252
+ });
253
+ if (!moved)
254
+ return null;
255
+ const wanted = new Set(place.groups);
256
+ const current = new Set(moved.groups ?? []);
257
+ for (const group of current) {
258
+ if (!wanted.has(group))
259
+ await tenant.removeGroupMember({ tenantRef, group, resourceId: id });
260
+ }
261
+ for (const group of wanted) {
262
+ if (!current.has(group))
263
+ await tenant.addGroupMember({ tenantRef, group, resourceId: id });
264
+ }
265
+ return tenant.getResource({ tenantRef, id });
266
+ }, by(actor)),
267
+ },
126
268
  waitlist: {
127
269
  list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
128
270
  async leave(tenantRef, id, actor) {
@@ -308,6 +450,8 @@ const AGENDA_LIMIT = 200;
308
450
  const AGENDA_SHOWN = 30;
309
451
  const ACTIVITY_LIMIT = 20;
310
452
  const WAITLIST_SHOWN = 200;
453
+ /** Approvals the dashboard lists, pending and recent decided together, newest first. */
454
+ const APPROVALS_SHOWN = 100;
311
455
  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;
312
456
  /** How the booking rules form names each rule. */
313
457
  const RULE_LIMITS = SLOTLOCK_BOOKING_RULE_LIMITS;
@@ -378,12 +522,122 @@ const ICONS = Object.freeze({
378
522
  waitlist: icon('<path d="M4 6h16M4 12h10M4 18h6"/><circle cx="18" cy="16" r="3"/>'),
379
523
  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"/>'),
380
524
  assistant: icon('<path d="M4 5h16v11H9l-5 4z"/><path d="M8 9h8M8 12h5"/>'),
525
+ plan: icon('<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M7 15h4"/>'),
526
+ teams: icon('<circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19c0-3.3 2.7-5 6-5s6 1.7 6 5M15 14.5c3 0 6 1.4 6 4.5"/>'),
381
527
  settings: icon('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
382
528
  check: icon('<path d="M5 12l5 5L20 7"/>'),
529
+ approvals: icon('<circle cx="12" cy="12" r="8"/><path d="M8.5 12.5l2.5 2.5 4.5-5"/>'),
383
530
  signOut: icon('<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 17l-5-5 5-5M5 12h11"/>'),
384
531
  plus: icon('<path d="M12 5v14M5 12h14"/>'),
532
+ google: new Html('<svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="fill" d="M15.68 8.18c0-.57-.05-1.11-.15-1.64H8v3.1h4.3a3.68 3.68 0 0 1-1.6 2.41v2h2.58c1.51-1.39 2.4-3.44 2.4-5.87zM8 16c2.16 0 3.97-.72 5.29-1.94l-2.58-2c-.72.48-1.63.76-2.71.76-2.08 0-3.85-1.41-4.48-3.3H.86v2.07A8 8 0 0 0 8 16zM3.52 9.52a4.8 4.8 0 0 1 0-3.04V4.41H.86a8 8 0 0 0 0 7.18zM8 3.18c1.17 0 2.23.4 3.06 1.2l2.29-2.3A8 8 0 0 0 .86 4.41l2.66 2.07C4.15 4.59 5.92 3.18 8 3.18z"/></svg>'),
385
533
  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>'),
386
534
  });
535
+ /** How the dashboard names each kind of resource. */
536
+ const KIND_NAMES = Object.freeze({
537
+ person: 'Person',
538
+ room: 'Room',
539
+ desk: 'Desk',
540
+ space: 'Space',
541
+ vehicle: 'Vehicle',
542
+ machine: 'Machine',
543
+ equipment: 'Equipment',
544
+ service: 'Service',
545
+ other: 'Not said',
546
+ });
547
+ /** What a profile field it could not keep must be, in a person's words. */
548
+ const PROFILE_PROBLEMS = Object.freeze({
549
+ name: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.`,
550
+ description: `A description is at most ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description.toLocaleString('en')} characters.`,
551
+ kind: 'Choose a kind from the list.',
552
+ capacity: `Capacity is a whole number from 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.capacity.toLocaleString('en')}. Leave it empty for none.`,
553
+ labels: `Up to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.labels} labels, each 1 to 64 letters, digits, dots, dashes or underscores.`,
554
+ attributes: 'Those attributes are not ones Slotlock can keep.',
555
+ other: 'That profile is not one Slotlock can keep.',
556
+ });
557
+ const GROUPING_REF_RULE = 'A reference is 1 to 100 lowercase letters, digits, dots, dashes or underscores, starting with a letter or digit.';
558
+ /** What a person is told when a location or group change is refused, by the store's code. */
559
+ const GROUPING_PROBLEMS = Object.freeze({
560
+ location_ref: { status: 400, text: GROUPING_REF_RULE },
561
+ location_name: { status: 400, text: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.` },
562
+ location_parent: { status: 400, text: 'Choose where it is from the list, or nowhere.' },
563
+ location_timezone: {
564
+ status: 400,
565
+ text: 'A time zone is a name such as Europe/London. Leave it empty to take the one of the location it is in.',
566
+ },
567
+ location_other: { status: 400, text: 'That location is not one Slotlock can keep.' },
568
+ location_not_found: { status: 404, text: 'That location is not one of yours.' },
569
+ location_too_deep: { status: 409, text: `Locations go ${SLOTLOCK_MAX_LOCATION_DEPTH} levels deep at most.` },
570
+ location_cycle: { status: 409, text: 'A location cannot be inside itself, or inside a location that is inside it.' },
571
+ location_limit_reached: {
572
+ status: 409,
573
+ text: `You have ${SLOTLOCK_MAX_LOCATIONS.toLocaleString('en')} locations, the most there can be.`,
574
+ },
575
+ location_in_use: {
576
+ status: 409,
577
+ text: 'Move its resources, and the locations inside it, somewhere else first.',
578
+ },
579
+ group_ref: { status: 400, text: GROUPING_REF_RULE },
580
+ group_name: { status: 400, text: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.` },
581
+ group_description: {
582
+ status: 400,
583
+ text: `A description is at most ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description.toLocaleString('en')} characters.`,
584
+ },
585
+ group_other: { status: 400, text: 'That group is not one Slotlock can keep.' },
586
+ group_not_found: { status: 404, text: 'That group is not one of yours.' },
587
+ group_limit_reached: {
588
+ status: 409,
589
+ text: `You have ${SLOTLOCK_MAX_RESOURCE_GROUPS.toLocaleString('en')} groups, the most there can be.`,
590
+ },
591
+ resource_group_limit_reached: {
592
+ status: 409,
593
+ text: `A resource is in ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE} groups at most.`,
594
+ },
595
+ });
596
+ /** The sentence for a refused location or group change, or null when it is not one. */
597
+ function groupingProblem(error) {
598
+ const code = error?.code;
599
+ if (code === 'invalid_location' || code === 'invalid_group') {
600
+ const kind = code === 'invalid_location' ? 'location' : 'group';
601
+ const field = error.field;
602
+ return GROUPING_PROBLEMS[`${kind}_${typeof field === 'string' ? field : 'other'}`] ?? GROUPING_PROBLEMS[`${kind}_other`] ?? null;
603
+ }
604
+ return typeof code === 'string' ? (GROUPING_PROBLEMS[code] ?? null) : null;
605
+ }
606
+ /** Each location's place from the top, as "London › Floor 3", by reference. */
607
+ function locationPaths(locations) {
608
+ const byRef = new Map(locations.map((location) => [location.ref, location]));
609
+ const paths = new Map();
610
+ for (const location of locations) {
611
+ const names = [];
612
+ let at = location;
613
+ for (let steps = 0; at && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
614
+ names.unshift(at.name);
615
+ at = at.parentRef === null ? undefined : byRef.get(at.parentRef);
616
+ }
617
+ paths.set(location.ref, names.join(' › '));
618
+ }
619
+ return paths;
620
+ }
621
+ /** Whether `ref` is `ancestor`, or a location inside it. */
622
+ function locationWithin(locations, ref, ancestor) {
623
+ let at = ref;
624
+ for (let steps = 0; at !== null && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
625
+ if (at === ancestor)
626
+ return true;
627
+ at = locations.get(at)?.parentRef ?? null;
628
+ }
629
+ return false;
630
+ }
631
+ /** The time zone a location gives a resource: its own, or the nearest one above it, and where from. */
632
+ function locationZone(locations, location) {
633
+ let at = location;
634
+ for (let steps = 0; at && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
635
+ if (at.timezone !== null)
636
+ return { timezone: at.timezone, from: at };
637
+ at = at.parentRef === null ? undefined : locations.get(at.parentRef);
638
+ }
639
+ return null;
640
+ }
387
641
  /** How the overview names each change the feed records. */
388
642
  const CHANGE_LABELS = Object.freeze({
389
643
  'hold.created': { text: 'Hold placed', tone: 'neutral' },
@@ -404,7 +658,26 @@ const CHANGE_LABELS = Object.freeze({
404
658
  'coverage.stale': { text: 'Calendar sync is behind', tone: 'bad' },
405
659
  'waitlist.offered': { text: 'Waitlist offer sent', tone: 'good' },
406
660
  'waitlist.expired': { text: 'Waitlist offer lapsed', tone: 'neutral' },
661
+ 'approval.requested': { text: 'Waiting for approval', tone: 'neutral' },
662
+ 'approval.granted': { text: 'Approved and booked', tone: 'good' },
663
+ 'approval.declined': { text: 'Approval declined', tone: 'bad' },
664
+ 'approval.expired': { text: 'Approval not decided in time', tone: 'bad' },
407
665
  'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
666
+ 'usage.threshold_reached': { text: 'Monthly quota nearly or fully used', tone: 'bad' },
667
+ 'usage.period_closed': { text: 'Month of use closed', tone: 'neutral' },
668
+ 'resource.created': { text: 'Resource added', tone: 'good' },
669
+ 'resource.updated': { text: 'Resource changed', tone: 'neutral' },
670
+ 'resource.archived': { text: 'Resource archived', tone: 'neutral' },
671
+ 'resource.restored': { text: 'Resource restored', tone: 'good' },
672
+ 'resource.deleted': { text: 'Resource deleted', tone: 'bad' },
673
+ 'key.created': { text: 'API key created', tone: 'good' },
674
+ 'key.rotated': { text: 'API key rotated', tone: 'neutral' },
675
+ 'key.revoked': { text: 'API key revoked', tone: 'bad' },
676
+ 'webhook.created': { text: 'Webhook added', tone: 'good' },
677
+ 'webhook.enabled': { text: 'Webhook enabled', tone: 'good' },
678
+ 'webhook.deleted': { text: 'Webhook deleted', tone: 'neutral' },
679
+ 'coverage.recorded': { text: 'Calendar synced', tone: 'neutral' },
680
+ 'coverage.deleted': { text: 'Calendar sync removed', tone: 'neutral' },
408
681
  });
409
682
  const zoned = (zone, parts) => {
410
683
  try {
@@ -510,6 +783,10 @@ function formatDate(date) {
510
783
  return '—';
511
784
  return `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
512
785
  }
786
+ /** A UTC day as people write it: `9 Dec 2026`. */
787
+ function dayOf(date) {
788
+ return zoned('UTC', { day: 'numeric', month: 'short', year: 'numeric' }).format(date);
789
+ }
513
790
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
514
791
  /** A table's date: the day (`19 Oct 2026`), with the full UTC time on hover and for machines. */
515
792
  function dateCell(date) {
@@ -645,6 +922,8 @@ export function createSlotlockDashboard(options) {
645
922
  const base = publicUrl.pathname.replace(/\/$/, '');
646
923
  const root = `${base}/dashboard`;
647
924
  const callbackUrl = `${origin}${root}/callback`;
925
+ const publicRoot = `${origin}${root}`;
926
+ const contentSecurityPolicy = policyFor(options.billing);
648
927
  // Its cookies are Secure, but a first visit over http:// could still be answered by someone else:
649
928
  // a browser that has seen this goes straight to https:// for a year. Not for a local http one.
650
929
  const hsts = publicUrl.protocol === 'https:' ? { 'Strict-Transport-Security': 'max-age=31536000' } : {};
@@ -657,14 +936,45 @@ export function createSlotlockDashboard(options) {
657
936
  const userUrl = options.github.userUrl ?? 'https://api.github.com/user';
658
937
  const membershipsUrl = options.github.membershipsUrl ??
659
938
  'https://api.github.com/user/memberships/orgs?state=active&per_page=100';
660
- const served = options.organizations ? new Set(options.organizations) : null;
939
+ const served = Array.isArray(options.organizations) ? new Set(options.organizations) : null;
661
940
  if (served && (served.size === 0 || [...served].some((id) => !ORGANIZATION_ID.test(id)))) {
662
941
  throw new Error('Slotlock dashboard organizations must be GitHub organization ids');
663
942
  }
943
+ // Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
944
+ const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
945
+ // Plans whether or not billing sells them; billing's own reader when only it is given.
946
+ const plans = options.plans ??
947
+ (options.billing
948
+ ? {
949
+ plan: options.billing.plan,
950
+ ...(options.billing.enrol ? { enrol: options.billing.enrol } : {}),
951
+ contact: options.billing.contact,
952
+ }
953
+ : undefined);
954
+ if (plans?.contact) {
955
+ const protocol = new URL(plans.contact).protocol;
956
+ if (protocol !== 'https:' && protocol !== 'mailto:')
957
+ throw new Error('plans.contact must be an https or mailto URL');
958
+ }
959
+ if (options.organizations === 'enabled' && !teams) {
960
+ throw new Error('Slotlock dashboard organizations "enabled" needs the teams store');
961
+ }
962
+ /** Sign-in reads the person's organizations: a fixed list, or those turned on. */
963
+ const readsOrganizations = served !== null || teams !== null;
964
+ /** Each session's organizations turned on, read with it (teams mode). */
965
+ const enabledOf = new WeakMap();
664
966
  const brandName = options.name ?? 'Slotlock';
665
967
  if (!SLOTLOCK_DASHBOARD_NAME_PATTERN.test(brandName) || brandName.trim() !== brandName) {
666
968
  throw new Error('Slotlock dashboard name must be 1-60 characters, without <, > or control or invisible characters');
667
969
  }
970
+ for (const [label, url] of [
971
+ ['termsUrl', options.termsUrl],
972
+ ['privacyUrl', options.privacyUrl],
973
+ ]) {
974
+ if (url !== undefined && !/^https:\/\/[^\s"'<>`]+$/.test(url)) {
975
+ throw new Error(`Slotlock dashboard ${label} must be an https URL`);
976
+ }
977
+ }
668
978
  const fetchImpl = options.fetch ?? fetch;
669
979
  const now = options.now ?? Date.now;
670
980
  if (Buffer.byteLength(options.sessionSecret, 'utf8') < 32) {
@@ -672,8 +982,10 @@ export function createSlotlockDashboard(options) {
672
982
  }
673
983
  const secret = Buffer.from(options.sessionSecret, 'utf8');
674
984
  // A list, or exactly '*': any other string would match ids by substring.
675
- const allowed = (uid) => options.allowedUsers === '*' ||
676
- (Array.isArray(options.allowedUsers) && options.allowedUsers.includes(uid));
985
+ // A GitHub person by their bare id, a Google one as google:<subject>.
986
+ const allowed = (uid, idp) => options.allowedUsers === '*' ||
987
+ (Array.isArray(options.allowedUsers) && options.allowedUsers.includes(idp === 'google' ? `google:${uid}` : uid));
988
+ const google = served ? null : (options.google ?? null);
677
989
  let signInsInFlight = 0;
678
990
  /** Sign-ins in flight by client address. */
679
991
  const signInsByClient = new Map();
@@ -741,18 +1053,39 @@ export function createSlotlockDashboard(options) {
741
1053
  typeof payload.uid !== 'string' ||
742
1054
  typeof payload.login !== 'string' ||
743
1055
  typeof payload.sid !== 'string' ||
744
- !allowed(payload.uid) ||
1056
+ (payload.idp !== undefined && (payload.idp !== 'google' || !google)) ||
1057
+ !allowed(payload.uid, payload.idp) ||
745
1058
  !organizationsHold(payload) ||
1059
+ (payload.env !== undefined && payload.env !== 'test') ||
746
1060
  (await options.state.sessionEnded(payload.sid, payload.exp))) {
747
1061
  return null;
748
1062
  }
749
- return payload;
1063
+ const session = payload;
1064
+ if (teams) {
1065
+ // Which of the person's organizations are on now: one turned off since is left at once.
1066
+ const enabled = session.orgs?.length ? await teams.enabled(session.orgs.map((org) => org.id)) : new Set();
1067
+ if (session.org !== undefined && !enabled.has(session.org)) {
1068
+ const { org: _, ...personal } = session;
1069
+ enabledOf.set(personal, enabled);
1070
+ return personal;
1071
+ }
1072
+ enabledOf.set(session, enabled);
1073
+ }
1074
+ return session;
750
1075
  }
751
1076
  /**
752
1077
  * With organizations, the session names one the server still serves, from its own list; without
753
1078
  * them, it names none. A session from the other mode is signed out.
754
1079
  */
755
1080
  function organizationsHold(payload) {
1081
+ if (teams) {
1082
+ if (payload.orgs === undefined)
1083
+ return payload.org === undefined;
1084
+ if (!Array.isArray(payload.orgs))
1085
+ return false;
1086
+ const listed = payload.orgs.filter((org) => isRecord(org) && typeof org.id === 'string' && typeof org.login === 'string');
1087
+ return payload.org === undefined || (typeof payload.org === 'string' && listed.some((org) => org.id === payload.org));
1088
+ }
756
1089
  if (!served)
757
1090
  return payload.org === undefined && payload.orgs === undefined;
758
1091
  if (typeof payload.org !== 'string' || !served.has(payload.org) || !Array.isArray(payload.orgs)) {
@@ -763,15 +1096,20 @@ export function createSlotlockDashboard(options) {
763
1096
  const csrfToken = (session) => createHmac('sha256', secret)
764
1097
  .update(`slotlock-dashboard-csrf-v1.${session.sid}`)
765
1098
  .digest('base64url');
766
- const tenantOf = (session) => served ? `github-org:${session.org}` : `github:${session.uid}`;
1099
+ /** The tenant the session acts in: the workspace's, or its test environment's (`~test`). */
1100
+ const tenantOf = (session) => `${session.org !== undefined ? `github-org:${session.org}` : personOf(session)}${session.env === 'test' ? SLOTLOCK_TEST_TENANT_SUFFIX : ''}`;
767
1101
  /** Who did something, for the record: the person, whatever tenant they act in. */
768
- const personOf = (session) => `github:${session.uid}`;
1102
+ function personOf(session) {
1103
+ return session.idp === 'google' ? `google:${session.uid}` : `github:${session.uid}`;
1104
+ }
1105
+ /** How the dashboard shows the person: their GitHub login, or their Google account's email. */
1106
+ const shownAs = (session) => (session.idp === 'google' ? session.login : `@${session.login}`);
769
1107
  function respond(status, body, headers = {}, cookies = []) {
770
1108
  const response = new Response(String(body), {
771
1109
  status,
772
1110
  headers: {
773
1111
  'Content-Type': 'text/html; charset=utf-8',
774
- 'Content-Security-Policy': CONTENT_SECURITY_POLICY,
1112
+ 'Content-Security-Policy': contentSecurityPolicy,
775
1113
  'Cache-Control': 'no-store',
776
1114
  'X-Frame-Options': 'DENY',
777
1115
  'X-Content-Type-Options': 'nosniff',
@@ -794,6 +1132,9 @@ export function createSlotlockDashboard(options) {
794
1132
  ...(options.resources.waitlist
795
1133
  ? [{ section: 'waitlist', label: 'Waitlist', path: '/waitlist', icon: ICONS.waitlist }]
796
1134
  : []),
1135
+ ...(options.resources.approvals
1136
+ ? [{ section: 'approvals', label: 'Approvals', path: '/approvals', icon: ICONS.approvals }]
1137
+ : []),
797
1138
  { section: 'keys', label: 'API keys', path: '/keys', icon: ICONS.keys },
798
1139
  ...(options.resources.webhooks
799
1140
  ? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
@@ -801,14 +1142,25 @@ export function createSlotlockDashboard(options) {
801
1142
  ...(assistantStore()
802
1143
  ? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
803
1144
  : []),
1145
+ ...(plans ? [{ section: 'plan', label: 'Usage', path: '/plan', icon: ICONS.plan }] : []),
1146
+ ...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
804
1147
  { section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
805
1148
  ];
1149
+ /** Live or Test (RFC 0003 G9): the same workspace, a tenant of its own for trying things. */
1150
+ function environmentSwitch(session) {
1151
+ const choice = (value, label) => html `<button type="submit" name="environment" value="${value}"${(session.env === 'test') === (value === 'test') ? html ` aria-pressed="true"` : html ` aria-pressed="false"`}>${label}</button>`;
1152
+ return html `<form class="environment" method="post" action="${root}/environment" aria-label="Environment"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}">${choice('live', 'Live')}${choice('test', 'Test')}</form>`;
1153
+ }
806
1154
  /** The workspace the session acts in: its organization, or the person themselves. */
807
1155
  function workspace(session) {
808
1156
  const current = session.orgs?.find((org) => org.id === session.org);
809
1157
  const choices = (session.orgs ?? []).filter((org) => served?.has(org.id));
810
1158
  const name = current?.login ?? session.login;
811
1159
  const badge = html `<span class="avatar" aria-hidden="true">${name.slice(0, 1).toUpperCase()}</span>`;
1160
+ const enabled = teams ? (session.orgs ?? []).filter((org) => enabledOf.get(session)?.has(org.id)) : [];
1161
+ if (teams && enabled.length > 0) {
1162
+ 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">Workspace</label><select id="organization" name="organization" title="${name}"><option value=""${current ? html `` : html ` selected`}>${shownAs(session)}</option>${enabled.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>`;
1163
+ }
812
1164
  if (current && choices.length > 1) {
813
1165
  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" title="${current.login}">${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>`;
814
1166
  }
@@ -823,6 +1175,7 @@ export function createSlotlockDashboard(options) {
823
1175
  <meta name="viewport" content="width=device-width, initial-scale=1">
824
1176
  <meta name="referrer" content="${REFERRER_POLICY}">
825
1177
  <title>${title} · ${brandName}</title>
1178
+ <link rel="icon" href="${root}/favicon.svg?v=${ASSET_VERSIONS.icon}" type="image/svg+xml">
826
1179
  <link rel="stylesheet" href="${root}/style.css?v=${ASSET_VERSIONS.css}">
827
1180
  <script src="${root}/app.js?v=${ASSET_VERSIONS.js}" defer></script>
828
1181
  </head>`;
@@ -846,44 +1199,77 @@ ${live}
846
1199
  ${skip}
847
1200
  <div class="app">
848
1201
  <header class="side">
849
- <div class="side-top">${brand}${workspace(session)}</div>
1202
+ <div class="side-top">${brand}${workspace(session)}${environmentSwitch(session)}</div>
850
1203
  <nav class="nav" aria-label="Dashboard">${links}</nav>
851
- <div class="me"><span class="avatar me-avatar" aria-hidden="true">${session.login.slice(0, 1).toUpperCase()}</span><span class="login" title="@${session.login}">@${session.login}</span><form method="post" action="${root}/sign-out"><input type="hidden" name="csrf" value="${csrfToken(session)}"><button class="quiet sign-out" type="submit" title="Sign out">${ICONS.signOut}<span class="sign-out-label">Sign out</span></button></form></div>
1204
+ <div class="me"><span class="avatar me-avatar" aria-hidden="true">${session.login.slice(0, 1).toUpperCase()}</span><span class="login" title="${shownAs(session)}">${shownAs(session)}</span><form method="post" action="${root}/sign-out"><input type="hidden" name="csrf" value="${csrfToken(session)}"><button class="quiet sign-out" type="submit" title="Sign out">${ICONS.signOut}<span class="sign-out-label">Sign out</span></button></form></div>
852
1205
  </header>
853
- <main class="page" id="main">${content}</main>
1206
+ <main class="page" id="main">${session.env === 'test'
1207
+ ? html `<p class="test-banner">Test environment: its own resources, keys, webhooks and history, on your plan's limits and never billed. Its keys start <code>slk_test_</code>.</p>`
1208
+ : html ``}${content}</main>
854
1209
  </div>
855
1210
  ${live}
856
1211
  </body>
857
1212
  </html>`;
858
1213
  }
859
- const noticeOf = (notice) => notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``;
1214
+ // A notice is an alert only when the page answers with an error: a done or saved message is a
1215
+ // status, so success is never painted in the error colour.
1216
+ const noticeOf = (notice, status = 200) => notice
1217
+ ? status >= 400
1218
+ ? html `<p class="notice" role="alert">${notice}</p>`
1219
+ : html `<p class="notice done" role="status">${notice}</p>`
1220
+ : html ``;
860
1221
  function signedOutPage(status, notice, cookies = []) {
861
1222
  const content = html `<section class="sign-in">
862
1223
  <span class="sign-in-mark">${ICONS.mark}</span>
863
1224
  <h1>Sign in to ${brandName}</h1>
864
1225
  <p>Create keys for your agents, add what they book, and watch what they do.</p>
865
- ${noticeOf(notice)}
866
- <a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a>
867
- <p class="fine">${served
868
- ? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and the organizations it serves.`
869
- : `${brandName} reads only your public GitHub profile, and keeps only your user id and login.`}</p>
1226
+ ${noticeOf(notice, status)}
1227
+ <a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a>${google ? html `<a class="button wide" href="${root}/sign-in?with=google">${ICONS.google}Continue with Google</a>` : html ``}
1228
+ ${options.termsUrl
1229
+ ? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
1230
+ ? html ` The <a href="${options.privacyUrl}">Privacy notice</a> says what is kept.`
1231
+ : html ``}</p>`
1232
+ : html ``}
1233
+ <p class="fine">${teams
1234
+ ? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and up to ${String(MAX_SESSION_ORGANIZATIONS)} of those organizations, for you to work in the ones turned on.`
1235
+ : served
1236
+ ? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and the organizations it serves.`
1237
+ : `${brandName} reads only your public GitHub profile, and keeps only your user id and login.`}${google ? ` With Google, it reads your account's id and email address, and keeps only those.` : ''}</p>
870
1238
  </section>`;
871
1239
  return respond(status, layout('Sign in', content, null), {}, cookies);
872
1240
  }
1241
+ /** A key's own limits (RFC 0004 A5) as fields, filled with what it has. */
1242
+ const keyLimitFields = (limits) => html `<label>Live holds at most<input name="max_holds" inputmode="numeric" pattern="[0-9]*" maxlength="5" value="${limits?.maxHolds !== undefined ? String(limits.maxHolds) : ''}" placeholder="Server's"></label>
1243
+ <label>Hours held at most<input name="max_held_hours" inputmode="numeric" pattern="[0-9]*" maxlength="6" value="${limits?.maxHeldHours !== undefined ? String(limits.maxHeldHours) : ''}" placeholder="Server's"></label>
1244
+ <label>Requests a minute<input name="requests_per_minute" inputmode="numeric" pattern="[0-9]*" maxlength="6" value="${limits?.requestsPerMinute !== undefined ? String(limits.requestsPerMinute) : ''}" placeholder="Server's"></label>
1245
+ <label class="choice"><input type="checkbox" name="requires_approval" value="on"${limits?.requiresApproval ? html ` checked` : html ``}> Its bookings wait for approval</label>`;
1246
+ /** A key's limits in words, for its row. */
1247
+ const keyLimitsText = (limits) => [
1248
+ limits.maxHolds !== undefined ? `${String(limits.maxHolds)} live holds` : '',
1249
+ limits.maxHeldHours !== undefined ? `${String(limits.maxHeldHours)} hours held` : '',
1250
+ limits.requestsPerMinute !== undefined ? `${String(limits.requestsPerMinute)} requests a minute` : '',
1251
+ limits.requiresApproval ? 'bookings need approval' : '',
1252
+ ]
1253
+ .filter(Boolean)
1254
+ .join(', ');
873
1255
  function keyRows(keys, token, session) {
874
1256
  return keys.map((key) => {
875
1257
  const expired = key.expiresAt !== null && key.expiresAt.getTime() <= now();
876
1258
  const status = key.revokedAt ? 'Revoked' : expired ? 'Expired' : 'Active';
877
1259
  const actions = status === 'Active'
878
- ? 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>
1260
+ ? html `<details class="act"><summary>Limits</summary><div class="pop"><p>Hold this key below the server's limits. Empty is the server's.</p>
1261
+ <form method="post" action="${root}/keys/limits"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${key.id}">${keyLimitFields(key.limits)}<button type="submit">Save limits</button></form></div></details>
1262
+ <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>
879
1263
  <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>
880
1264
  <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>
881
1265
  <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>`
882
1266
  : html ``;
883
1267
  return html `<tr>
884
- <td data-label="Name" class="strong"><span class="cell"><span class="truncate" title="${key.name}">${key.name}</span>${served ? html `<span class="meta">Created by ${key.createdBy === personOf(session) ? 'you' : key.createdBy?.startsWith('github:') ? `GitHub user ${key.createdBy.slice(7)}` : 'someone unknown'}</span>` : html ``}</span></td>
1268
+ <td data-label="Name" class="strong"><span class="cell"><span class="truncate" title="${key.name}">${key.name}</span>${session.org !== undefined ? html `<span class="meta">Created by ${key.createdBy === personOf(session) ? 'you' : key.createdBy?.startsWith('github:') ? `GitHub user ${key.createdBy.slice(7)}` : key.createdBy?.startsWith('google:') ? 'a Google user' : 'someone unknown'}</span>` : html ``}</span></td>
885
1269
  <td data-label="Key"><code class="chip">${key.prefix}…</code></td>
886
- <td data-label="Access">${key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only'}</td>
1270
+ <td data-label="Access">${key.resourceScope || key.limits
1271
+ ? html `<span class="cell"><span>${accessOf(key)}</span>${key.resourceScope ? html `<span class="meta wrap">Only ${[...key.resourceScope.locations, ...key.resourceScope.groups.map((group) => `group ${group}`)].join(', ')}</span>` : html ``}${key.limits ? html `<span class="meta wrap">At most ${keyLimitsText(key.limits)}</span>` : html ``}</span>`
1272
+ : accessOf(key)}</td>
887
1273
  <td data-label="Created">${dateCell(key.createdAt)}</td>
888
1274
  <td data-label="Last used">${key.lastUsedAt ? dateCell(key.lastUsedAt) : html `<span class="muted">Never</span>`}</td>
889
1275
  <td data-label="Expires">${key.expiresAt ? dateCell(key.expiresAt) : html `<span class="muted">Never</span>`}</td>
@@ -896,7 +1282,8 @@ ${noticeOf(notice)}
896
1282
  /** A list with nothing in it yet: what goes here, and the one thing to do next. */
897
1283
  const emptyState = (glyph, text, action) => html `<div class="empty-state"><span class="empty-icon">${glyph}</span><p class="empty">${text}</p>${action ? html `<a class="button" href="${action.href}">${ICONS.plus}${action.label}</a>` : html ``}</div>`;
898
1284
  /** A resource as people name it: its reference, else the start of its id. */
899
- const resourceName = (resource, id) => resource?.externalRef ?? `${id.slice(0, 8)}…`;
1285
+ // Its profile's name, else its reference, else the start of its id.
1286
+ const resourceName = (resource, id) => resource?.name ?? resource?.externalRef ?? `${id.slice(0, 8)}…`;
900
1287
  function agendaPanel(entries, byId, more) {
901
1288
  if (entries.length === 0) {
902
1289
  return html `<p class="empty">Nothing booked in the next ${String(AGENDA_DAYS)} days.</p>`;
@@ -926,78 +1313,269 @@ ${noticeOf(notice)}
926
1313
  }
927
1314
  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 ``}`;
928
1315
  }
1316
+ /**
1317
+ * What each change says in the feed, as a sentence: who did it (`verb` then the resource), or what
1318
+ * happened (`passive`, the resource in place of {X}). Every change type the feed records has one,
1319
+ * so the feed never shows a raw type such as resource.updated.
1320
+ */
1321
+ const ACTIVITY = Object.freeze({
1322
+ 'hold.created': { verb: 'held' },
1323
+ 'hold.updated': { verb: 'changed the hold on' },
1324
+ 'hold.confirmed': { verb: 'booked', after: ' from a hold' },
1325
+ 'hold.released': { verb: 'released the hold on' },
1326
+ 'hold.expired': { passive: 'The hold on {X} expired' },
1327
+ 'reservation.created': { verb: 'booked' },
1328
+ 'reservation.updated': { verb: 'moved a booking on' },
1329
+ 'reservation.cancelled': { verb: 'cancelled a booking on' },
1330
+ 'reservation.checked_in': { verb: 'checked in to' },
1331
+ 'reservation.no_show': { passive: '{X} was released after a no-show' },
1332
+ 'event.created': { verb: 'added an event on' },
1333
+ 'event.updated': { verb: 'changed an event on' },
1334
+ 'event.cancelled': { verb: 'cancelled an event on' },
1335
+ 'event.timezone_shifted': { passive: 'An event on {X} moved with a time zone change' },
1336
+ 'event.timezone_conflict': { passive: 'An event on {X} clashes after a time zone change' },
1337
+ 'coverage.stale': { passive: 'The calendar sync for {X} is behind' },
1338
+ 'coverage.recorded': { passive: 'The calendar for {X} synced' },
1339
+ 'coverage.deleted': { verb: 'removed the calendar sync for' },
1340
+ 'waitlist.offered': { passive: 'A waitlist offer went out for {X}' },
1341
+ 'waitlist.expired': { passive: 'A waitlist offer for {X} lapsed' },
1342
+ 'approval.requested': { passive: 'A booking on {X} waits for approval' },
1343
+ 'approval.granted': { verb: 'approved a booking on' },
1344
+ 'approval.declined': { verb: 'declined a booking on' },
1345
+ 'approval.expired': { passive: 'A booking on {X} was not approved in time' },
1346
+ 'resource.created': { verb: 'added' },
1347
+ 'resource.updated': { verb: 'edited' },
1348
+ 'resource.archived': { verb: 'archived' },
1349
+ 'resource.restored': { verb: 'restored' },
1350
+ 'resource.deleted': { verb: 'deleted' },
1351
+ 'key.created': { verb: 'created an API key' },
1352
+ 'key.rotated': { verb: 'rotated an API key' },
1353
+ 'key.revoked': { verb: 'revoked an API key' },
1354
+ 'webhook.created': { verb: 'added a webhook' },
1355
+ 'webhook.enabled': { verb: 'turned a webhook back on' },
1356
+ 'webhook.disabled': { passive: 'A webhook was turned off' },
1357
+ 'webhook.deleted': { verb: 'deleted a webhook' },
1358
+ 'usage.period_closed': { passive: 'A month of use closed' },
1359
+ });
1360
+ /** Changes about a resource, which name it; the rest stand alone. */
1361
+ const ABOUT_A_RESOURCE = /^(hold|reservation|event|coverage|waitlist|approval|resource)\./;
929
1362
  function activityPanel(changes, byId, keys, session) {
930
1363
  if (changes.length === 0) {
931
1364
  return html `<p class="empty">No activity yet. What agents book, move and cancel shows here.</p>`;
932
1365
  }
933
1366
  const keyNames = new Map(keys.map((key) => [`api_key:${key.id}`, key.name]));
934
- return html `<ol class="feed">${changes.map((change) => {
935
- const label = CHANGE_LABELS[change.type] ?? { text: change.type, tone: 'neutral' };
936
- const who = change.actor === null
937
- ? brandName
938
- : change.actor === personOf(session)
939
- ? 'You'
940
- : (keyNames.get(change.actor) ??
941
- (change.actor.startsWith('api_key:')
942
- ? 'A deleted key'
943
- : /^github:\d+$/.test(change.actor)
944
- ? `GitHub user ${change.actor.slice(7)}`
945
- : /^dashboard-assistant:github:\d+$/.test(change.actor)
946
- ? change.actor === assistantActor(session)
947
- ? `Assistant for @${session.login}`
948
- : `Assistant for GitHub user ${change.actor.slice(27)}`
949
- : change.actor.slice(0, 60)));
950
- const where = change.resourceId
951
- ? resourceName(byId.get(change.resourceId), change.resourceId)
952
- : null;
953
- return html `<li class="event"><span class="dot ${label.tone}" aria-hidden="true"></span><span class="what" title="${where ? `${label.text} on ${where}` : label.text}"><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>`;
1367
+ const whoOf = (actor) => actor === null
1368
+ ? brandName
1369
+ : actor === personOf(session)
1370
+ ? 'You'
1371
+ : (keyNames.get(actor) ??
1372
+ (actor.startsWith('api_key:')
1373
+ ? 'A deleted key'
1374
+ : /^github:\d+$/.test(actor)
1375
+ ? `GitHub user ${actor.slice(7)}`
1376
+ : /^google:/.test(actor)
1377
+ ? 'A Google user'
1378
+ : /^dashboard-assistant:(github|google):/.test(actor)
1379
+ ? actor === assistantActor(session)
1380
+ ? `Assistant for ${shownAs(session)}`
1381
+ : actor.startsWith('dashboard-assistant:google:')
1382
+ ? 'Assistant for a Google user'
1383
+ : `Assistant for GitHub user ${actor.slice(27)}`
1384
+ : actor.slice(0, 60)));
1385
+ // Repeats in a row (one actor, one change, one resource) read as one line with a count.
1386
+ const runs = [];
1387
+ for (const change of changes) {
1388
+ const last = runs.at(-1);
1389
+ if (last && last.change.actor === change.actor && last.change.type === change.type && last.change.resourceId === change.resourceId) {
1390
+ last.times += 1;
1391
+ }
1392
+ else {
1393
+ runs.push({ change, times: 1 });
1394
+ }
1395
+ }
1396
+ return html `<ol class="feed">${runs.map(({ change, times }) => {
1397
+ const tone = CHANGE_LABELS[change.type]?.tone ?? 'neutral';
1398
+ const where = change.resourceId ? resourceName(byId.get(change.resourceId), change.resourceId) : null;
1399
+ const object = where ?? (ABOUT_A_RESOURCE.test(change.type) ? 'a resource' : null);
1400
+ const words = ACTIVITY[change.type];
1401
+ let sentence;
1402
+ if (change.type === 'usage.threshold_reached') {
1403
+ const metric = typeof change.data.metric === 'string' ? change.data.metric : 'use';
1404
+ const percent = typeof change.data.percent === 'number' ? change.data.percent : null;
1405
+ sentence = html `${percent === null ? 'A monthly quota' : `${String(percent)}% of this month's ${metric}`} used`;
1406
+ }
1407
+ else if (words?.verb) {
1408
+ sentence = html `<strong>${whoOf(change.actor)}</strong> ${words.verb}${object ? html ` <strong>${object}</strong>` : html ``}${words.after ?? ''}`;
1409
+ }
1410
+ else if (words?.passive) {
1411
+ const [before, rest] = words.passive.split('{X}');
1412
+ sentence = rest === undefined ? html `${before}` : html `${before}<strong>${object ?? 'a resource'}</strong>${rest}`;
1413
+ }
1414
+ else {
1415
+ // A change type this dashboard does not know yet: its label, or its name in words.
1416
+ const label = CHANGE_LABELS[change.type]?.text ?? change.type.replace(/[._]/g, ' ').replace(/^./, (first) => first.toUpperCase());
1417
+ sentence = html `${label}${object ? html ` <span class="on">on</span> <strong>${object}</strong>` : html ``}`;
1418
+ }
1419
+ return html `<li class="event"><span class="dot ${tone}" aria-hidden="true"></span><span class="what"><span class="title">${sentence}${times > 1 ? html ` <span class="times">${times === 2 ? 'twice' : `${String(times)} times`}</span>` : html ``}</span></span><time class="when" datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></li>`;
954
1420
  })}</ol>`;
955
1421
  }
1422
+ /** The MCP and A2A endpoints with Copy, and the command that adds Slotlock to an MCP client. */
1423
+ const connectAgent = () => html `${endpoints()}<details class="command"><summary>Claude Code command</summary><div class="copy-block"><pre><code id="mcp-command">claude mcp add --transport http slotlock ${mcpUrl} --header "Authorization: Bearer YOUR_KEY"</code></pre><button class="quiet" type="button" data-copy="mcp-command">Copy</button></div></details>`;
956
1424
  async function overviewPage(session, status = 200, notice) {
957
1425
  const tenantRef = tenantOf(session);
958
1426
  const start = new Date(now());
959
1427
  const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
960
- const [keys, resources, agenda, changes] = await Promise.all([
1428
+ const assistant = assistantStore();
1429
+ const [keys, resources, agenda, changes, approvals, waitlist, model] = await Promise.all([
961
1430
  options.keys.list({ tenantRef }),
962
1431
  options.resources.list(tenantRef),
963
1432
  options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
964
1433
  options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
1434
+ options.resources.approvals?.list(tenantRef, APPROVALS_SHOWN),
1435
+ options.resources.waitlist?.list(tenantRef, WAITLIST_SHOWN),
1436
+ assistant?.getSettings(tenantRef),
965
1437
  ]);
1438
+ // The plan is Live's: the test environment has none of its own.
1439
+ const standing = plans && session.env !== 'test' ? await planStanding(session, 'overview', keys) : null;
966
1440
  const byId = new Map(resources.map((resource) => [resource.id, resource]));
967
1441
  const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
968
1442
  const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
969
1443
  const booked = agenda?.filter((entry) => entry.kind !== 'hold').length ?? 0;
970
1444
  const held = agenda?.filter((entry) => entry.kind === 'hold').length ?? 0;
971
- const stat = (label, value, href) => html `<a class="stat" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
1445
+ const waiting = approvals?.filter((approval) => approval.status === 'pending').length ?? 0;
1446
+ const attention = [
1447
+ ...(standing?.attention ?? []),
1448
+ ...(waiting > 0
1449
+ ? [
1450
+ attentionItem('info', `${String(waiting)} ${waiting === 1 ? 'booking waits' : 'bookings wait'} for your approval.`, {
1451
+ href: `${root}/approvals`,
1452
+ label: 'Review',
1453
+ }),
1454
+ ]
1455
+ : []),
1456
+ ];
1457
+ const admin = isAdmin(session);
972
1458
  const steps = [
973
- { done: resources.length > 0, text: 'Add what your agents book', href: `${root}/resources` },
974
- { done: active.length > 0, text: 'Create a key for an agent', href: `${root}/keys` },
975
- { done: (changes?.length ?? 0) > 0, text: 'Connect the agent and book', href: `${root}/keys` },
1459
+ { done: resources.length > 0, title: 'Add a resource', action: { href: `${root}/resources`, label: 'Add resource' } },
1460
+ { done: active.length > 0, title: 'Create an API key', action: { href: `${root}/keys#new-key`, label: 'Create key' } },
1461
+ // Done once an agent has used a key.
1462
+ { done: active.some((key) => key.lastUsedAt !== null), title: 'Connect an agent', action: { href: `${root}#connect`, label: 'Connect' } },
1463
+ ...(assistant
1464
+ ? [
1465
+ admin
1466
+ ? { done: Boolean(model), title: 'Connect a model to the assistant', action: { href: `${root}/settings`, label: 'Connect' } }
1467
+ : { done: Boolean(model), title: 'Try the assistant', action: { href: `${root}/assistant`, label: 'Open' } },
1468
+ ]
1469
+ : []),
976
1470
  ];
977
- const content = html `${noticeOf(notice)}
978
- ${pageHead('Overview')}
979
- ${steps.every((step) => step.done)
1471
+ const done = steps.filter((step) => step.done).length;
1472
+ const next = steps.findIndex((step) => !step.done);
1473
+ // Nothing made and nothing happened yet: only the way in.
1474
+ const fresh = resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
1475
+ const setupCard = next === -1
980
1476
  ? html ``
981
- : 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>`}
982
- <div class="stats">
983
- ${agenda ? stat(`Booked, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`) : html ``}
984
- ${agenda ? stat('Live holds', counted(held), `${root}#upcoming`) : html ``}
985
- ${stat('Resources', String(resources.length), `${root}/resources`)}
986
- ${stat('Active keys', String(active.length), `${root}/keys`)}
987
- </div>
1477
+ : html `<section class="panel setup-card"><div class="panel-head"><h2>Get started</h2><span class="meta">${String(done)} of ${String(steps.length)}</span></div><div class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</div><ol class="setup-steps">${steps.map((step, index) => html `<li class="${step.done ? 'done' : index === next ? 'next' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span><span class="step-title">${step.title}${step.done ? html `<span class="visually-hidden"> (done)</span>` : html ``}</span>${index === next ? html `<a class="button primary" href="${step.action.href}">${step.action.label}</a>` : html ``}</li>`)}</ol></section>`;
1478
+ // Mostly set up: one line that leads to the next step.
1479
+ const setupRow = next === -1
1480
+ ? html ``
1481
+ : html `<a class="setup-row" href="${steps[next]?.action.href ?? root}"><span class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</span><span>Get started · ${String(done)} of ${String(steps.length)} · <strong>${steps[next]?.title ?? ''}</strong></span></a>`;
1482
+ const stat = (label, value, href, tone = '') => html `<a class="stat${tone}" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
1483
+ const tiles = [
1484
+ ...(agenda ? [stat(`Bookings, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`), stat('Live holds', counted(held), `${root}#upcoming`)] : []),
1485
+ ...(approvals ? [stat('Waiting for approval', String(waiting), `${root}/approvals`, waiting > 0 ? ' attention-stat' : '')] : []),
1486
+ ...(waitlist ? [stat('On the waitlist', String(waitlist.filter((entry) => entry.status !== 'expired').length), `${root}/waitlist`)] : []),
1487
+ stat('Resources', String(resources.length), `${root}/resources`),
1488
+ stat('Active keys', String(active.length), `${root}/keys`),
1489
+ ].slice(0, 4);
1490
+ const planCard = standing
1491
+ ? html `<section class="panel plan-card"><div class="panel-head"><h2>${standing.name}</h2><a href="${root}/plan">Usage</a></div><div class="meters stacked">${standing.meters}</div>${standing.canUpgrade && standing.pressing
1492
+ ? html `<a class="button primary" href="${root}/plan#upgrade">Upgrade</a>`
1493
+ : html ``}</section>`
1494
+ : html ``;
1495
+ const connectCard = html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
1496
+ const body = fresh
1497
+ ? standing
1498
+ ? html `<div class="split">${setupCard}<div class="column">${planCard}</div></div>`
1499
+ : setupCard
1500
+ : html `${done * 2 >= steps.length ? setupRow : setupCard}
1501
+ <div class="stats">${tiles}</div>
1502
+ ${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
988
1503
  <div class="split">
1504
+ <div class="column">
989
1505
  ${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
990
- ${changes ? html `<section class="panel"><h2>Agent activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
1506
+ ${changes ? html `<section class="panel"><h2>Activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
1507
+ </div>
1508
+ <div class="column">${planCard}${connectCard}</div>
991
1509
  </div>`;
1510
+ const content = html `${noticeOf(notice, status)}
1511
+ ${pageHead('Overview')}
1512
+ ${attentionList(attention)}
1513
+ ${fresh && session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
1514
+ ${body}`;
992
1515
  return respond(status, layout('Overview', content, session, 'overview'));
993
1516
  }
1517
+ /** In Test: load a scenario to try an agent against, move waiting forward, or start again (RFC 0004 A6). */
1518
+ function sandboxPanel(session, resourceCount) {
1519
+ const token = csrfToken(session);
1520
+ const hidden = html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
1521
+ const names = { 'car-rental': 'Car rental', clinic: 'Clinic', desks: 'Desks and a hall' };
1522
+ return html `<section class="panel" id="sandbox"><h2>Sandbox</h2>
1523
+ <p class="fine">Test only. Try an agent against sample data, make holds and approvals that would lapse lapse now, or start again. Keys and webhooks stay.</p>
1524
+ ${resourceCount === 0
1525
+ ? html `<form class="create" method="post" action="${root}/sandbox/seed">${hidden}<label>Sample data<select name="scenario">${SLOTLOCK_SANDBOX_SCENARIOS.map((scenario) => html `<option value="${scenario}">${names[scenario]}</option>`)}</select></label><button class="primary" type="submit">Load it</button></form>`
1526
+ : html `<form class="create" method="post" action="${root}/sandbox/fast-forward">${hidden}<label>Move waiting forward<select name="minutes"><option value="15">15 minutes</option><option value="60">An hour</option><option value="1440">A day</option></select></label><button type="submit">Move forward</button></form>
1527
+ <details class="act danger"><summary>Start again</summary><div class="pop"><p>Every resource, booking, hold and policy in Test goes. Keys and webhooks stay.</p><form method="post" action="${root}/sandbox/reset">${hidden}<button class="danger" type="submit">Empty Test</button></form></div></details>`}
1528
+ </section>`;
1529
+ }
1530
+ async function sandboxAction(session, form, action) {
1531
+ const sandbox = options.resources.sandbox;
1532
+ if (!sandbox || session.env !== 'test')
1533
+ return overviewPage(session, 400, 'The sandbox is in Test only.');
1534
+ const tenantRef = tenantOf(session);
1535
+ try {
1536
+ if (action === 'seed') {
1537
+ const scenario = form.get('scenario');
1538
+ if (!SLOTLOCK_SANDBOX_SCENARIOS.includes(scenario))
1539
+ return overviewPage(session, 400, 'Choose a sample from the list.');
1540
+ const seeded = await sandbox.seed(tenantRef, scenario);
1541
+ return overviewPage(session, 200, `Loaded ${String(seeded.resources)} resources and ${String(seeded.bookings)} bookings for tomorrow.`);
1542
+ }
1543
+ if (action === 'reset') {
1544
+ await sandbox.reset(tenantRef);
1545
+ return redirect(303, `${root}#sandbox`);
1546
+ }
1547
+ const minutes = Number(form.get('minutes'));
1548
+ if (![15, 60, 1440].includes(minutes))
1549
+ return overviewPage(session, 400, 'Choose how far from the list.');
1550
+ const moved = await sandbox.fastForward(tenantRef, minutes);
1551
+ return overviewPage(session, 200, `${String(moved.holds)} holds and ${String(moved.approvals)} approvals lapsed.`);
1552
+ }
1553
+ catch (error) {
1554
+ if (error.code === 'sandbox_not_empty') {
1555
+ return overviewPage(session, 409, 'Test has resources already. Start again first.');
1556
+ }
1557
+ throw error;
1558
+ }
1559
+ }
994
1560
  function endpoints() {
995
1561
  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>`;
996
1562
  }
1563
+ const accessOf = (key) => key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only';
997
1564
  async function keysPage(session, status = 200, notice) {
998
- const keys = await options.keys.list({ tenantRef: tenantOf(session) });
1565
+ const tenantRef = tenantOf(session);
1566
+ const grouping = options.resources.grouping;
1567
+ const [keys, locations, groups] = await Promise.all([
1568
+ options.keys.list({ tenantRef }),
1569
+ grouping?.locations(tenantRef),
1570
+ grouping?.groups(tenantRef),
1571
+ ]);
999
1572
  const token = csrfToken(session);
1000
- const content = html `${noticeOf(notice)}
1573
+ const paths = locationPaths(locations ?? []);
1574
+ // A key limited to locations or groups (RFC 0003 G6), when the tenant has any to choose.
1575
+ const limitChoices = (locations?.length ?? 0) + (groups?.length ?? 0) > 0
1576
+ ? html `<details class="types"><summary>Limit to locations or groups (optional)</summary><div class="checks">${(locations ?? []).map((location) => html `<label class="choice"><input type="checkbox" name="limit_location" value="${location.ref}"> ${paths.get(location.ref) ?? location.name}</label>`)}${(groups ?? []).map((group) => html `<label class="choice"><input type="checkbox" name="limit_group" value="${group.ref}"> Group: ${group.name}</label>`)}</div><p class="fine">The key then reaches only the resources there (a location with every location in it), read and write only. Developer plan and up.</p></details>`
1577
+ : html ``;
1578
+ const content = html `${noticeOf(notice, status)}
1001
1579
  ${pageHead('API keys')}
1002
1580
  <section class="panel">
1003
1581
  <h2>Connect an agent</h2>
@@ -1013,19 +1591,39 @@ ${endpoints()}
1013
1591
  <label>Access<select name="access"><option value="read_write">Read and write</option><option value="read">Read only</option><option value="full">Full, with the application API</option></select></label>
1014
1592
  <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>
1015
1593
  <button class="primary" type="submit">Create key</button>
1594
+ ${limitChoices}
1595
+ <details class="types"><summary>Agent limits (optional)</summary><div class="checks">${keyLimitFields()}</div><p class="fine">Below the server's own limits, never above them. A key whose bookings wait for approval sends each to the Approvals page.</p></details>
1016
1596
  </form>
1017
1597
  </section>
1018
1598
  ${keys.length === 0
1019
1599
  ? emptyState(ICONS.keys, 'No keys yet. Give every agent its own, with the least access it needs.', { href: '#key-name', label: 'Create a key' })
1020
- : html `${served ? html `<p class="fine">Each key acts in this organization only, and every member can rotate or revoke it.</p>` : 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>
1600
+ : html `${session.org !== undefined ? html `<p class="fine">Each key acts in this organization only, and every member can rotate or revoke it.</p>` : 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>
1021
1601
  <tbody>${keyRows(keys, token, session)}</tbody></table></div>`}`;
1022
1602
  return respond(status, layout('API keys', content, session, 'keys'));
1023
1603
  }
1604
+ /**
1605
+ * How many resources the tenant may have here, and how many it has as that limit counts them: the
1606
+ * dashboard's own limit, or its plan's when lower (archived resources count against a plan).
1607
+ */
1608
+ async function resourceAllowance(tenantRef, resources) {
1609
+ const plan = plans ? await plans.plan(tenantRef) : null;
1610
+ if (!plan || plan.limits.resources >= SLOTLOCK_DASHBOARD_MAX_RESOURCES) {
1611
+ return { used: resources.length, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES, planned: false, archived: 0 };
1612
+ }
1613
+ const counted = options.resources.count ? await options.resources.count(tenantRef) : resources.length;
1614
+ return {
1615
+ used: counted,
1616
+ limit: plan.limits.resources,
1617
+ planned: true,
1618
+ archived: Math.max(0, counted - resources.length),
1619
+ };
1620
+ }
1024
1621
  async function resourcesPage(session, status = 200, notice) {
1025
1622
  const resources = await options.resources.list(tenantOf(session));
1623
+ const allowance = await resourceAllowance(tenantOf(session), resources);
1026
1624
  const token = csrfToken(session);
1027
- const content = html `${noticeOf(notice)}
1028
- ${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : undefined)}
1625
+ const content = html `${noticeOf(notice, status)}
1626
+ ${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}${resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : html ``}</div>`)}
1029
1627
  <section class="panel" id="add-resource">
1030
1628
  <h2>Add a resource</h2>
1031
1629
  <form class="create" method="post" action="${root}/resources">
@@ -1035,39 +1633,124 @@ ${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${r
1035
1633
  <label>Time zone<input name="timezone" required maxlength="64" value="UTC" placeholder="Europe/London"></label>
1036
1634
  <button class="primary" type="submit">Add resource</button>
1037
1635
  </form>
1038
- <p class="fine">${String(resources.length)} of ${String(SLOTLOCK_DASHBOARD_MAX_RESOURCES)}. Adding a reference you have changes its time zone.</p>
1636
+ <p class="fine">${String(allowance.used)} of ${String(allowance.limit)}${allowance.planned ? html ` on your plan${allowance.archived > 0 ? html `, ${String(allowance.archived)} of them archived (archived resources count until deleted)` : html ``}` : html ``}. Adding a reference you have changes its time zone.</p>
1039
1637
  </section>
1040
1638
  ${resources.length === 0
1041
1639
  ? emptyState(ICONS.resources, 'No resources yet. Agents can only book what is listed here.', { href: '#reference', label: 'Add a resource' })
1042
- : 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 class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${hoursSummary(resource.availabilityRules)}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
1640
+ : 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 class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.name && resource.externalRef ? html `<span class="meta" title="Reference ${resource.externalRef}">${resource.externalRef}</span>` : html ``}${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${hoursSummary(resource.availabilityRules)}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
1043
1641
  return respond(status, layout('Resources', content, session, 'resources'));
1044
1642
  }
1045
- async function settingsPage(session, status = 200, notice) {
1046
- const rules = options.availability ?? [];
1047
- const current = session.orgs?.find((org) => org.id === session.org);
1048
- const content = html `${noticeOf(notice)}
1049
- ${pageHead('Settings')}
1050
- ${await assistantSettingsPanel(session)}
1051
- <section class="panel">
1052
- <h2>Endpoints</h2>
1053
- ${endpoints()}
1054
- </section>
1055
- <section class="panel">
1056
- <h2>Server hours</h2>
1057
- ${rules.length === 0
1058
- ? html `<p class="empty">None: agents find a free slot only on resources with hours of their own.</p>`
1059
- : html `<ul class="hours">${rules.map((rule) => html `<li>${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}</li>`)}</ul>`}
1060
- <p class="fine">For resources without hours of their own, in each resource's time zone. Set by the server's operator.</p>
1643
+ /** The tenant's locations and groups (RFC 0003 G2, G3): what they are, and adding and changing them. */
1644
+ async function locationsPage(session, status = 200, notice) {
1645
+ const grouping = options.resources.grouping;
1646
+ if (!grouping)
1647
+ return nothingHere(session);
1648
+ const tenantRef = tenantOf(session);
1649
+ const [locations, groups] = await Promise.all([grouping.locations(tenantRef), grouping.groups(tenantRef)]);
1650
+ const token = csrfToken(session);
1651
+ const byRef = new Map(locations.map((location) => [location.ref, location]));
1652
+ const paths = locationPaths(locations);
1653
+ const sent = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
1654
+ // Where a location may be: nowhere, or any location but itself and those inside it.
1655
+ const insideChoices = (selected, except) => html `<option value=""${selected === null ? html ` selected` : html ``}>Nowhere (at the top)</option>${locations
1656
+ .filter((location) => except === undefined || !locationWithin(byRef, location.ref, except))
1657
+ .map((location) => html `<option value="${location.ref}"${location.ref === selected ? html ` selected` : html ``}>${paths.get(location.ref) ?? location.name}</option>`)}`;
1658
+ const nameLimit = String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.name);
1659
+ const locationRows = locations.map((location) => {
1660
+ const parent = location.parentRef === null ? undefined : paths.get(location.parentRef);
1661
+ const zone = locationZone(byRef, location);
1662
+ return html `<tr>
1663
+ <td data-label="Location" class="strong"><span class="cell"><span class="truncate" title="${paths.get(location.ref) ?? location.name}">${location.name}</span><span class="meta">${location.ref}${parent ? ` · in ${parent}` : ''}</span></span></td>
1664
+ <td data-label="Time zone">${zone ? (zone.from === location ? zone.timezone : html `<span class="cell"><span>${zone.timezone}</span><span class="meta">from ${zone.from.name}</span></span>`) : html `<span class="muted">None</span>`}</td>
1665
+ <td data-label="Resources">${String(location.resources)}</td>
1666
+ <td class="actions"><details class="act"><summary>Edit</summary><div class="pop"><form method="post" action="${root}/locations">${sent()}<input type="hidden" name="ref" value="${location.ref}">
1667
+ <label>Name<input name="name" required maxlength="${nameLimit}" value="${location.name}"></label>
1668
+ <label>Inside<select name="parent">${insideChoices(location.parentRef, location.ref)}</select></label>
1669
+ <label>Time zone<input name="timezone" maxlength="100" value="${location.timezone ?? ''}" placeholder="As where it is"></label>
1670
+ <button type="submit">Save location</button></form></div></details><details class="act danger"><summary>Delete</summary><div class="pop"><p>${location.resources > 0 ? 'Move its resources somewhere else first.' : 'Locations inside it must be moved or deleted first.'}</p>
1671
+ <form method="post" action="${root}/locations/delete">${sent()}<input type="hidden" name="ref" value="${location.ref}"><button class="danger" type="submit">Delete location</button></form></div></details></td>
1672
+ </tr>`;
1673
+ });
1674
+ const groupRows = groups.map((group) => html `<tr>
1675
+ <td data-label="Group" class="strong"><span class="cell"><span class="truncate" title="${group.name}">${group.name}</span><span class="meta">${group.ref}</span></span></td>
1676
+ <td data-label="Description" class="wrap">${group.description ?? ''}</td>
1677
+ <td data-label="Resources">${String(group.members)}</td>
1678
+ <td class="actions"><details class="act"><summary>Edit</summary><div class="pop"><form method="post" action="${root}/groups">${sent()}<input type="hidden" name="ref" value="${group.ref}">
1679
+ <label>Name<input name="name" required maxlength="${nameLimit}" value="${group.name}"></label>
1680
+ <label>Description<textarea name="description" rows="2" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}">${group.description ?? ''}</textarea></label>
1681
+ <button type="submit">Save group</button></form></div></details><details class="act danger"><summary>Delete</summary><div class="pop"><p>Its resources stay as they are, in no group of this name.</p>
1682
+ <form method="post" action="${root}/groups/delete">${sent()}<input type="hidden" name="ref" value="${group.ref}"><button class="danger" type="submit">Delete group</button></form></div></details></td>
1683
+ </tr>`);
1684
+ const content = html `${noticeOf(notice, status)}
1685
+ <p><a class="back" href="${root}/resources">Resources</a></p>
1686
+ ${pageHead('Locations and groups')}
1687
+ <section class="panel" id="locations">
1688
+ <h2>Locations</h2>
1689
+ <form class="create" method="post" action="${root}/locations">${sent()}
1690
+ <label>Reference<input id="location-ref" name="ref" required maxlength="100" placeholder="floor-3"></label>
1691
+ <label class="grow">Name<input name="name" required maxlength="${nameLimit}" placeholder="Floor 3"></label>
1692
+ <label>Inside<select name="parent">${insideChoices(null)}</select></label>
1693
+ <label>Time zone<input name="timezone" maxlength="100" placeholder="As where it is"></label>
1694
+ <button class="primary" type="submit">Add location</button>
1695
+ </form>
1696
+ <p class="fine">Where resources are: a site, a building, a floor, up to ${String(SLOTLOCK_MAX_LOCATION_DEPTH)} levels. Agents searching a location search every location inside it too. A resource added at a location over the API without a time zone takes the location's, or the nearest one above it. Adding a reference you have changes that location.</p>
1697
+ ${locations.length === 0
1698
+ ? html `<p class="empty">No locations yet.</p>`
1699
+ : html `<div class="table"><table><thead><tr><th>Location</th><th>Time zone</th><th>Resources</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${locationRows}</tbody></table></div>`}
1061
1700
  </section>
1062
- <section class="panel">
1063
- <h2>Account</h2>
1064
- <dl class="facts"><dt>Signed in as</dt><dd>@${session.login}</dd>${current
1065
- ? 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 ``}`
1066
- : html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
1067
- ${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
1701
+ <section class="panel" id="groups">
1702
+ <h2>Groups</h2>
1703
+ <form class="create" method="post" action="${root}/groups">${sent()}
1704
+ <label>Reference<input id="group-ref" name="ref" required maxlength="100" placeholder="video-rooms"></label>
1705
+ <label class="grow">Name<input name="name" required maxlength="${nameLimit}" placeholder="Rooms with video"></label>
1706
+ <label class="grow">Description<input name="description" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}" placeholder="Optional"></label>
1707
+ <button class="primary" type="submit">Add group</button>
1708
+ </form>
1709
+ <p class="fine">Any set of resources, such as a team or the rooms with video, wherever they are. A resource is in up to ${String(SLOTLOCK_MAX_GROUPS_PER_RESOURCE)} groups; choose them on its page. Adding a reference you have changes that group.</p>
1710
+ ${groups.length === 0
1711
+ ? html `<p class="empty">No groups yet.</p>`
1712
+ : html `<div class="table"><table><thead><tr><th>Group</th><th>Description</th><th>Resources</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${groupRows}</tbody></table></div>`}
1068
1713
  </section>`;
1714
+ return respond(status, layout('Locations and groups', content, session, 'resources'));
1715
+ }
1716
+ /** Settings' sections, each its own page: the assistant's form apart from what is only read. */
1717
+ const SETTINGS_SECTIONS = Object.freeze([
1718
+ { id: 'assistant', label: 'Assistant', path: '/settings' },
1719
+ { id: 'endpoints', label: 'Endpoints', path: '/settings/endpoints' },
1720
+ { id: 'hours', label: 'Hours', path: '/settings/hours' },
1721
+ { id: 'account', label: 'Account', path: '/settings/account' },
1722
+ ]);
1723
+ async function settingsPage(session, status = 200, notice, draft, section = 'assistant') {
1724
+ const store = assistantStore();
1725
+ const sections = SETTINGS_SECTIONS.filter((entry) => entry.id !== 'assistant' || store);
1726
+ const shown = section === 'assistant' && !store ? 'endpoints' : section;
1727
+ const connected = store ? Boolean(await store.getSettings(tenantOf(session))) : false;
1728
+ const nav = html `<nav class="subnav" aria-label="Settings">${sections.map((entry) => html `<a href="${root}${entry.path}"${entry.id === shown ? html ` aria-current="page"` : html ``}>${entry.label}${entry.id === 'assistant' && connected ? html `<span class="nav-dot" aria-hidden="true"></span>` : html ``}</a>`)}</nav>`;
1729
+ let body;
1730
+ if (shown === 'assistant') {
1731
+ body = await assistantSettingsSection(session, status, notice, draft);
1732
+ }
1733
+ else if (shown === 'endpoints') {
1734
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">MCP</span><div class="row-value">${copyField('mcp-url', mcpUrl)}</div></div><div class="row"><span class="row-label">A2A</span><div class="row-value">${copyField('a2a-url', a2aUrl)}</div></div></div>`;
1735
+ }
1736
+ else if (shown === 'hours') {
1737
+ const rules = options.availability ?? [];
1738
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Default hours</span><div class="row-value${rules.length === 0 ? ' muted' : ''}">${rules.length === 0
1739
+ ? 'None. Agents find free time only on resources with hours of their own.'
1740
+ : html `${rules.map((rule, index) => html `${index > 0 ? html `<br>` : html ``}${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}`)}`}</div></div></div><p class="fine">In each resource's own time zone, for resources without hours of their own. Set by the server's operator.</p>`;
1741
+ }
1742
+ else {
1743
+ const current = session.orgs?.find((org) => org.id === session.org);
1744
+ const others = (session.orgs ?? []).filter((org) => org.id !== current?.id);
1745
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Signed in as</span><div class="row-value">${shownAs(session)}</div></div><div class="row"><span class="row-label">${current ? 'Organization' : 'Workspace'}</span><div class="row-value">${current ? current.login : 'Personal'}</div></div>${current && others.length > 0
1746
+ ? html `<div class="row"><span class="row-label">Also a member of</span><div class="row-value">${others.map((org) => org.login).join(', ')}</div></div>`
1747
+ : html ``}<div class="row"><span class="row-label">Session ends</span><div class="row-value">${formatDate(new Date(session.exp))}</div></div></div>${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}`;
1748
+ }
1749
+ const content = html `${pageHead('Settings')}<div class="settings">${nav}<div class="settings-body">${body}</div></div>`;
1069
1750
  return respond(status, layout('Settings', content, session, 'settings'));
1070
1751
  }
1752
+ /** A value to copy: monospace, cut with an ellipsis, with a Copy button (app.js). */
1753
+ const copyField = (id, value) => html `<dl class="endpoints"><dd><code id="${id}">${value}</code><button class="quiet" type="button" data-copy="${id}">Copy</button></dd></dl>`;
1071
1754
  /** The tenant's resource with that id and every resource (for names), or null for anything else. */
1072
1755
  async function resourceDetailPage(session, id, status = 200, notice) {
1073
1756
  const tenantRef = tenantOf(session);
@@ -1076,12 +1759,21 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
1076
1759
  return notFound(session);
1077
1760
  const start = new Date(now());
1078
1761
  const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
1079
- const [agenda, changes, keys, defaults] = await Promise.all([
1762
+ const grouping = options.resources.grouping;
1763
+ const [agenda, changes, keys, defaults, locations, groups, everyResource] = await Promise.all([
1080
1764
  options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
1081
1765
  options.resources.recentChanges?.(tenantRef, 100),
1082
1766
  options.keys.list({ tenantRef }),
1083
1767
  options.resources.ruleDefaults?.(tenantRef),
1768
+ grouping?.locations(tenantRef),
1769
+ grouping?.groups(tenantRef),
1770
+ // For a composite: its parts, or the wholes it can be a part of.
1771
+ options.resources.setParent ? options.resources.list(tenantRef) : undefined,
1084
1772
  ]);
1773
+ const parts = (everyResource?.filter((other) => other.parentId === resource.id) ?? []).sort((one, other) => (one.partIndex ?? 0) - (other.partIndex ?? 0));
1774
+ const wholes = everyResource?.filter((other) => other.id !== resource.id && other.parentId === undefined && other.archivedAt === undefined) ?? [];
1775
+ const paths = locationPaths(locations ?? []);
1776
+ const groupNames = new Map((groups ?? []).map((group) => [group.ref, group.name]));
1085
1777
  const byId = new Map([[resource.id, resource]]);
1086
1778
  const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
1087
1779
  const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
@@ -1103,10 +1795,11 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
1103
1795
  const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
1104
1796
  return html `<label>${label}<select name="${key}"><option value=""${chosen(undefined)}>${fallback}</option><option value="1"${chosen(true)}>On</option><option value="0"${chosen(false)}>Off</option></select></label>`;
1105
1797
  };
1106
- const content = html `${noticeOf(notice)}
1798
+ const content = html `${noticeOf(notice, status)}
1107
1799
  <p><a class="back" href="${root}/resources">Resources</a></p>
1108
1800
  ${pageHead(resourceName(resource, resource.id))}
1109
- <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>
1801
+ ${resource.description ? html `<p>${resource.description}</p>` : html ``}
1802
+ <dl class="facts">${resource.name && resource.externalRef ? html `<dt>Reference</dt><dd>${resource.externalRef}</dd>` : html ``}${resource.kind ? html `<dt>Kind</dt><dd>${KIND_NAMES[resource.kind]}</dd>` : html ``}${resource.capacity !== undefined ? html `<dt>Capacity</dt><dd>${String(resource.capacity)}</dd>` : html ``}${resource.labels ? html `<dt>Labels</dt><dd>${resource.labels.join(', ')}</dd>` : html ``}${resource.location ? html `<dt>Location</dt><dd>${paths.get(resource.location) ?? resource.locationName ?? resource.location}</dd>` : html ``}${resource.groups ? html `<dt>Groups</dt><dd>${resource.groups.map((ref) => groupNames.get(ref) ?? ref).join(', ')}</dd>` : html ``}<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>
1110
1803
  <div class="split">
1111
1804
  <div class="stack">
1112
1805
  ${options.resources.setRules
@@ -1131,6 +1824,25 @@ ${inheritedNote(inherited)}
1131
1824
  <p class="fine">Leave a field empty for no rule${inherited.size > 0 ? ' of its own' : ''}.</p>
1132
1825
  <button class="primary" type="submit">Save rules</button>
1133
1826
  </form>
1827
+ </section>`
1828
+ : html ``}
1829
+ ${options.resources.setProfile
1830
+ ? html `<section class="panel">
1831
+ <h2>Profile</h2>
1832
+ <form method="post" action="${root}/resources/profile">
1833
+ <input type="hidden" name="csrf" value="${token}">
1834
+ <input type="hidden" name="once" value="${formNonce(session)}">
1835
+ <input type="hidden" name="id" value="${resource.id}">
1836
+ <div class="grid-form">
1837
+ <label>Name<input name="name" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.name)}" value="${resource.name ?? ''}" placeholder="${resource.externalRef ?? ''}"></label>
1838
+ <label>Kind<select name="kind">${SLOTLOCK_RESOURCE_KINDS.map((kind) => html `<option value="${kind}"${(resource.kind ?? 'other') === kind ? html ` selected` : html ``}>${KIND_NAMES[kind]}</option>`)}</select></label>
1839
+ <label>Capacity<span class="unit-input"><input type="number" inputmode="numeric" min="1" max="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.capacity)}" name="capacity" value="${resource.capacity === undefined ? '' : String(resource.capacity)}"><span>people or seats</span></span></label>
1840
+ <label>Labels<input name="labels" value="${(resource.labels ?? []).join(', ')}" placeholder="video, accessible"></label>
1841
+ </div>
1842
+ <label>Description<textarea name="description" rows="3" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}">${resource.description ?? ''}</textarea></label>
1843
+ <p class="fine">Agents and people see the name instead of the reference. Kind, capacity and labels help them find it, and change nothing about how it is booked.${resource.attributes ? html ` Its attributes (${Object.keys(resource.attributes).join(', ')}) are set over the API.` : html ``}</p>
1844
+ <button class="primary" type="submit">Save profile</button>
1845
+ </form>
1134
1846
  </section>`
1135
1847
  : html ``}
1136
1848
  ${options.resources.setPool
@@ -1144,6 +1856,39 @@ ${options.resources.setPool
1144
1856
  <button type="submit">Save pool</button>
1145
1857
  </form>
1146
1858
  <p class="fine">Agents can book any free member of a pool. Leave it empty for none.</p>
1859
+ </section>`
1860
+ : html ``}
1861
+ ${options.resources.setParent && everyResource
1862
+ ? html `<section class="panel" id="composite">
1863
+ <h2>Composite</h2>
1864
+ ${parts.length > 0
1865
+ ? html `<p>A whole of ${String(parts.length)} part${parts.length === 1 ? '' : 's'}: ${parts.map((part, index) => html `${index > 0 ? ', ' : ''}<a href="${root}/resources/${part.id}">${resourceName(part, part.id)}</a>`)}. Booking it takes every part, and a booking of any part keeps it from being booked.</p>`
1866
+ : html `<form class="create" method="post" action="${root}/resources/parent">
1867
+ <input type="hidden" name="csrf" value="${token}">
1868
+ <input type="hidden" name="once" value="${formNonce(session)}">
1869
+ <input type="hidden" name="id" value="${resource.id}">
1870
+ <label>Part of<select name="parent"><option value=""${resource.parentId ? html `` : html ` selected`}>Nothing: it stands alone</option>${wholes.map((whole) => html `<option value="${whole.id}"${resource.parentId === whole.id ? html ` selected` : html ``}>${resourceName(whole, whole.id)}</option>`)}</select></label>
1871
+ <button type="submit">Save</button>
1872
+ </form>
1873
+ <p class="fine">A room of a hall, a half of a court: booking the whole then takes every part, and two parts never get in each other's way. It changes only while neither has a booking or hold to come.</p>`}
1874
+ </section>`
1875
+ : html ``}
1876
+ ${grouping && locations && groups
1877
+ ? html `<section class="panel" id="place">
1878
+ <h2>Location and groups</h2>
1879
+ ${locations.length === 0 && groups.length === 0
1880
+ ? html `<p class="empty">No locations or groups yet. <a href="${root}/locations">Add them</a>, then choose them here.</p>`
1881
+ : html `<form method="post" action="${root}/resources/place">
1882
+ <input type="hidden" name="csrf" value="${token}">
1883
+ <input type="hidden" name="once" value="${formNonce(session)}">
1884
+ <input type="hidden" name="id" value="${resource.id}">
1885
+ <label>Location<select name="location"><option value=""${resource.location ? html `` : html ` selected`}>None</option>${locations.map((location) => html `<option value="${location.ref}"${resource.location === location.ref ? html ` selected` : html ``}>${paths.get(location.ref) ?? location.name}</option>`)}</select></label>
1886
+ ${groups.length > 0
1887
+ ? html `<fieldset class="choices"><legend>Groups</legend><div class="checks">${groups.map((group) => html `<label class="choice"><input type="checkbox" name="group" value="${group.ref}"${resource.groups?.includes(group.ref) ? html ` checked` : html ``}> ${group.name}</label>`)}</div></fieldset>`
1888
+ : html ``}
1889
+ <button class="primary" type="submit">Save location and groups</button>
1890
+ </form>
1891
+ <p class="fine">Agents can search a location, with every location inside it, or a group, in one call. <a href="${root}/locations">Locations and groups</a></p>`}
1147
1892
  </section>`
1148
1893
  : html ``}
1149
1894
  </div>
@@ -1191,6 +1936,153 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1191
1936
  }
1192
1937
  return redirect(303, `${root}/resources/${id}`);
1193
1938
  }
1939
+ async function saveProfile(session, form) {
1940
+ const id = form.get('id') ?? '';
1941
+ const resource = await ownResource(session, id);
1942
+ if (!resource || !options.resources.setProfile)
1943
+ return notFound(session);
1944
+ const text = (key) => optionalField(form, key);
1945
+ const capacity = text('capacity');
1946
+ if (capacity !== null && !/^\d{1,6}$/.test(capacity)) {
1947
+ return resourceDetailPage(session, id, 400, PROFILE_PROBLEMS.capacity);
1948
+ }
1949
+ const kind = form.get('kind') ?? 'other';
1950
+ try {
1951
+ await options.resources.setProfile(tenantOf(session), id, {
1952
+ name: text('name'),
1953
+ description: text('description'),
1954
+ kind: kind,
1955
+ capacity: capacity === null ? null : Number(capacity),
1956
+ // Commas or spaces between them; a person may type them in capitals.
1957
+ labels: (form.get('labels') ?? '')
1958
+ .split(/[\s,]+/)
1959
+ .map((label) => label.trim().toLowerCase())
1960
+ .filter((label) => label !== ''),
1961
+ }, personOf(session));
1962
+ }
1963
+ catch (error) {
1964
+ if (error.code !== 'invalid_resource_profile')
1965
+ throw error;
1966
+ const field = error.field;
1967
+ return resourceDetailPage(session, id, 400, PROFILE_PROBLEMS[typeof field === 'string' && field in PROFILE_PROBLEMS ? field : 'other']);
1968
+ }
1969
+ return redirect(303, `${root}/resources/${id}`);
1970
+ }
1971
+ /** Text from a form, trimmed, or null when empty. */
1972
+ function optionalField(form, key) {
1973
+ const value = (form.get(key) ?? '').trim();
1974
+ return value === '' ? null : value;
1975
+ }
1976
+ async function saveLocation(session, form) {
1977
+ const grouping = options.resources.grouping;
1978
+ if (!grouping)
1979
+ return nothingHere(session);
1980
+ try {
1981
+ await grouping.putLocation(tenantOf(session), {
1982
+ // A person may type a reference in capitals; references are lowercase.
1983
+ ref: (form.get('ref') ?? '').trim().toLowerCase(),
1984
+ name: (form.get('name') ?? '').trim(),
1985
+ parentRef: optionalField(form, 'parent'),
1986
+ timezone: optionalField(form, 'timezone'),
1987
+ }, personOf(session));
1988
+ }
1989
+ catch (error) {
1990
+ const problem = groupingProblem(error);
1991
+ if (!problem)
1992
+ throw error;
1993
+ return locationsPage(session, problem.status, problem.text);
1994
+ }
1995
+ return redirect(303, `${root}/locations`);
1996
+ }
1997
+ async function saveGroup(session, form) {
1998
+ const grouping = options.resources.grouping;
1999
+ if (!grouping)
2000
+ return nothingHere(session);
2001
+ try {
2002
+ await grouping.putGroup(tenantOf(session), {
2003
+ ref: (form.get('ref') ?? '').trim().toLowerCase(),
2004
+ name: (form.get('name') ?? '').trim(),
2005
+ description: optionalField(form, 'description'),
2006
+ }, personOf(session));
2007
+ }
2008
+ catch (error) {
2009
+ const problem = groupingProblem(error);
2010
+ if (!problem)
2011
+ throw error;
2012
+ return locationsPage(session, problem.status, problem.text);
2013
+ }
2014
+ return redirect(303, `${root}/locations#groups`);
2015
+ }
2016
+ async function deleteGrouping(session, form, kind) {
2017
+ const grouping = options.resources.grouping;
2018
+ if (!grouping)
2019
+ return nothingHere(session);
2020
+ const ref = form.get('ref') ?? '';
2021
+ let deleted;
2022
+ try {
2023
+ deleted =
2024
+ kind === 'location'
2025
+ ? await grouping.deleteLocation(tenantOf(session), ref, personOf(session))
2026
+ : await grouping.deleteGroup(tenantOf(session), ref, personOf(session));
2027
+ }
2028
+ catch (error) {
2029
+ const problem = groupingProblem(error);
2030
+ if (!problem)
2031
+ throw error;
2032
+ return locationsPage(session, problem.status, problem.text);
2033
+ }
2034
+ if (!deleted) {
2035
+ const problem = GROUPING_PROBLEMS[`${kind}_not_found`];
2036
+ return locationsPage(session, problem.status, problem.text);
2037
+ }
2038
+ return redirect(303, `${root}/locations${kind === 'group' ? '#groups' : ''}`);
2039
+ }
2040
+ async function savePlace(session, form) {
2041
+ const id = form.get('id') ?? '';
2042
+ const resource = await ownResource(session, id);
2043
+ const grouping = options.resources.grouping;
2044
+ if (!resource || !grouping)
2045
+ return notFound(session);
2046
+ const groups = [...new Set(form.getAll('group'))];
2047
+ if (groups.length > SLOTLOCK_MAX_GROUPS_PER_RESOURCE) {
2048
+ const problem = GROUPING_PROBLEMS.resource_group_limit_reached;
2049
+ return resourceDetailPage(session, id, problem.status, problem.text);
2050
+ }
2051
+ try {
2052
+ const placed = await grouping.place(tenantOf(session), id, { location: optionalField(form, 'location'), groups }, personOf(session));
2053
+ if (!placed)
2054
+ return notFound(session);
2055
+ }
2056
+ catch (error) {
2057
+ const problem = groupingProblem(error);
2058
+ if (!problem)
2059
+ throw error;
2060
+ return resourceDetailPage(session, id, problem.status, problem.text);
2061
+ }
2062
+ return redirect(303, `${root}/resources/${id}`);
2063
+ }
2064
+ async function saveParent(session, form) {
2065
+ const id = form.get('id') ?? '';
2066
+ const resource = await ownResource(session, id);
2067
+ if (!resource || !options.resources.setParent)
2068
+ return notFound(session);
2069
+ const wanted = (form.get('parent') ?? '').trim();
2070
+ const parent = wanted === '' ? null : await ownResource(session, wanted);
2071
+ if (wanted !== '' && !parent)
2072
+ return resourceDetailPage(session, id, 400, 'That is not one of your resources.');
2073
+ try {
2074
+ const changed = await options.resources.setParent(tenantOf(session), id, parent?.id ?? null, personOf(session));
2075
+ if (!changed)
2076
+ return notFound(session);
2077
+ }
2078
+ catch (error) {
2079
+ const text = COMPOSITE_PROBLEMS[String(error?.code)];
2080
+ if (!text)
2081
+ throw error;
2082
+ return resourceDetailPage(session, id, 409, text);
2083
+ }
2084
+ return redirect(303, `${root}/resources/${id}`);
2085
+ }
1194
2086
  async function savePool(session, form) {
1195
2087
  const id = form.get('id') ?? '';
1196
2088
  const resource = await ownResource(session, id);
@@ -1207,6 +2099,17 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1207
2099
  }
1208
2100
  return redirect(303, `${root}/resources/${id}`);
1209
2101
  }
2102
+ /** An endpoint's filter (set over the API), in a few words. */
2103
+ function webhookFilterText(filter) {
2104
+ return [
2105
+ filter.resourceIds ? `${filter.resourceIds.length} resource${filter.resourceIds.length === 1 ? '' : 's'}` : null,
2106
+ filter.pools ? `pools ${filter.pools.join(', ')}` : null,
2107
+ filter.locations ? `locations ${filter.locations.join(', ')}` : null,
2108
+ filter.groups ? `groups ${filter.groups.join(', ')}` : null,
2109
+ ]
2110
+ .filter((part) => part !== null)
2111
+ .join('; ');
2112
+ }
1210
2113
  function endpointState(endpoint) {
1211
2114
  if (endpoint.disabledAt)
1212
2115
  return { text: 'Disabled', tone: 'revoked' };
@@ -1231,7 +2134,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1231
2134
  : '';
1232
2135
  return html `<tr>
1233
2136
  <td data-label="URL" class="strong"><code class="truncate url" title="${endpoint.url}">${endpoint.url}</code></td>
1234
- <td data-label="Events" class="wrap">${endpoint.eventTypes ? endpoint.eventTypes.join(', ') : 'All'}</td>
2137
+ <td data-label="Events" class="wrap">${endpoint.eventTypes ? endpoint.eventTypes.join(', ') : 'All'}${endpoint.filter ? html `<span class="meta">Only ${webhookFilterText(endpoint.filter)}</span>` : html ``}</td>
1235
2138
  <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>
1236
2139
  <td data-label="Added">${dateCell(endpoint.createdAt)}</td>
1237
2140
  <td class="actions">${endpoint.disabledAt
@@ -1240,7 +2143,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1240
2143
  <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>
1241
2144
  </tr>`;
1242
2145
  });
1243
- const content = html `${noticeOf(notice)}
2146
+ const content = html `${noticeOf(notice, status)}
1244
2147
  ${pageHead('Webhooks')}
1245
2148
  <div class="stats">
1246
2149
  <div class="stat"><span class="stat-label">Endpoints</span><span class="stat-value">${String(endpoints.length)}</span></div>
@@ -1271,7 +2174,7 @@ ${endpoints.length === 0
1271
2174
  const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
1272
2175
  if (!created.ok) {
1273
2176
  const message = created.code === 'webhook_limit_reached'
1274
- ? `You have ${String(created.limit)} webhooks, as many as Slotlock allows. Delete one first.`
2177
+ ? `You have ${String(created.limit)} webhooks, as many as ${plans ? 'your plan' : 'Slotlock'} allows. Delete one first${plans ? ', or see Usage' : ''}.`
1275
2178
  : created.code === 'invalid_webhook_url'
1276
2179
  ? 'Give an https URL, with no user name, password or #fragment.'
1277
2180
  : 'Choose events from the list.';
@@ -1340,7 +2243,7 @@ ${endpoints.length === 0
1340
2243
  });
1341
2244
  const waiting = entries.filter((entry) => entry.status === 'waiting').length;
1342
2245
  const offered = entries.filter((entry) => entry.status === 'offered').length;
1343
- const content = html `${noticeOf(notice)}
2246
+ const content = html `${noticeOf(notice, status)}
1344
2247
  ${pageHead('Waitlist')}
1345
2248
  <div class="stats">
1346
2249
  <div class="stat"><span class="stat-label">Waiting</span><span class="stat-value">${String(waiting)}</span></div>
@@ -1360,6 +2263,87 @@ ${entries.length === 0
1360
2263
  }
1361
2264
  return redirect(303, `${root}/waitlist`);
1362
2265
  }
2266
+ // ---- Approvals (RFC 0004 A4) ----
2267
+ async function approvalsPage(session, status = 200, notice) {
2268
+ const approvals = options.resources.approvals;
2269
+ if (!approvals)
2270
+ return notFound(session);
2271
+ const tenantRef = tenantOf(session);
2272
+ const [entries, resources] = await Promise.all([
2273
+ approvals.list(tenantRef, APPROVALS_SHOWN),
2274
+ options.resources.list(tenantRef),
2275
+ ]);
2276
+ const byId = new Map(resources.map((resource) => [resource.id, resource]));
2277
+ const token = csrfToken(session);
2278
+ const pending = entries.filter((entry) => entry.status === 'pending' && entry.expiresAt.getTime() > now());
2279
+ const decided = entries.filter((entry) => !pending.includes(entry));
2280
+ const what = (entry) => entry.resourceIds.map((id) => resourceName(byId.get(id), id)).join(', ');
2281
+ const when = (entry) => {
2282
+ const zone = byId.get(entry.resourceIds[0] ?? '')?.timezone ?? 'UTC';
2283
+ const day = dayLabel(entry.start, zone, now());
2284
+ return `${day.label} ${clock(entry.start, zone)}–${clock(entry.end, zone)}`;
2285
+ };
2286
+ const KINDS = {
2287
+ slotlock_create_event: 'Booking',
2288
+ slotlock_book_together: 'Bundle',
2289
+ slotlock_update_event: 'Move',
2290
+ };
2291
+ const form = (entry, decision, body) => html `<form method="post" action="${root}/approvals/decide"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${entry.id}"><input type="hidden" name="decision" value="${decision}">${body}</form>`;
2292
+ const pendingRows = pending.map((entry) => html `<tr>
2293
+ <td data-label="When" class="strong">${when(entry)}</td>
2294
+ <td data-label="What"><span class="cell"><span class="truncate" title="${what(entry)}">${what(entry)}</span><span class="meta">${KINDS[entry.operation]}, policy ${entry.policyRef}</span></span></td>
2295
+ <td data-label="Asked by"><code class="truncate" title="${entry.requestedBy ?? ''}">${entry.requestedBy ?? 'unknown'}</code></td>
2296
+ <td data-label="Decide by">${clock(entry.expiresAt, 'UTC')} UTC, ${dayLabel(entry.expiresAt, 'UTC', now()).label}</td>
2297
+ <td class="actions">${form(entry, 'grant', html `<button type="submit">Approve</button>`)}<details class="act danger"><summary>Decline</summary><div class="pop"><p>The held time is freed. The agent is told on its next try.</p>${form(entry, 'decline', html `<label>Reason (optional)<input name="reason" maxlength="500" autocomplete="off"></label><button class="danger" type="submit">Decline</button>`)}</div></details></td>
2298
+ </tr>`);
2299
+ const STATUS = {
2300
+ pending: { text: 'Lapsed', tone: 'revoked' },
2301
+ granted: { text: 'Approved', tone: 'active' },
2302
+ declined: { text: 'Declined', tone: 'revoked' },
2303
+ expired: { text: 'Expired', tone: 'revoked' },
2304
+ };
2305
+ const decidedRows = decided.map((entry) => html `<tr>
2306
+ <td data-label="When" class="strong">${when(entry)}</td>
2307
+ <td data-label="What"><span class="truncate" title="${what(entry)}">${what(entry)}</span></td>
2308
+ <td data-label="Status"><span class="cell"><span class="pill ${STATUS[entry.status].tone}">${STATUS[entry.status].text}</span>${entry.reason ? html `<span class="meta" title="${entry.reason}">${entry.reason}</span>` : html ``}</span></td>
2309
+ <td data-label="Decided by"><code class="truncate" title="${entry.decidedBy ?? ''}">${entry.decidedBy ?? '—'}</code></td>
2310
+ <td data-label="Asked">${ago(entry.createdAt, now())}</td>
2311
+ </tr>`);
2312
+ const content = html `${noticeOf(notice, status)}
2313
+ ${pageHead('Approvals')}
2314
+ <p class="fine">Bookings an approval policy holds for a person. Their time stays held until you decide or the wait ends. Policies are set through the API (slotlock_put_approval_policy).</p>
2315
+ <div class="stats">
2316
+ <div class="stat"><span class="stat-label">Waiting for you</span><span class="stat-value">${String(pending.length)}</span></div>
2317
+ </div>
2318
+ ${pending.length === 0
2319
+ ? emptyState(ICONS.approvals, 'Nothing is waiting. A policy that holds bookings for a person sends them here.')
2320
+ : html `<div class="table"><table><thead><tr><th>When</th><th>What</th><th>Asked by</th><th>Decide by</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${pendingRows}</tbody></table></div>`}
2321
+ ${decided.length === 0
2322
+ ? html ``
2323
+ : html `<h2>Decided</h2><div class="table"><table><thead><tr><th>When</th><th>What</th><th>Status</th><th>Decided by</th><th>Asked</th></tr></thead><tbody>${decidedRows}</tbody></table></div>`}`;
2324
+ return respond(status, layout('Approvals', content, session, 'approvals'));
2325
+ }
2326
+ async function decideApproval(session, form) {
2327
+ const approvals = options.resources.approvals;
2328
+ if (!approvals)
2329
+ return notFound(session);
2330
+ const decision = form.get('decision');
2331
+ if (decision !== 'grant' && decision !== 'decline')
2332
+ return approvalsPage(session, 400, 'Choose approve or decline.');
2333
+ const reason = (form.get('reason') ?? '').trim().slice(0, 500);
2334
+ const decided = await approvals.decide(tenantOf(session), form.get('id') ?? '', decision, personOf(session), reason || undefined);
2335
+ if (!decided.ok) {
2336
+ const messages = {
2337
+ approval_not_found: 'That approval is not one of yours.',
2338
+ approval_not_pending: 'That approval was already decided.',
2339
+ approval_expired: 'The wait for that approval ended: its time is no longer held.',
2340
+ overlap: 'That time is taken now, so it cannot be booked. Decline it instead.',
2341
+ revision_conflict: 'The agent changed that event since it asked. Decline this one; it can ask again.',
2342
+ };
2343
+ return approvalsPage(session, 409, messages[decided.code] ?? `That booking cannot be made now (${decided.code}). Decline it instead.`);
2344
+ }
2345
+ return redirect(303, `${root}/approvals`);
2346
+ }
1363
2347
  // ---- The assistant ----
1364
2348
  const assistantOptions = options.assistant;
1365
2349
  /** The assistant's records, when the assistant is on and the adapter keeps them. Per request (Workers). */
@@ -1383,6 +2367,23 @@ ${entries.length === 0
1383
2367
  if (assistantOptions && assistantOptions.secret === options.sessionSecret) {
1384
2368
  throw new Error('Slotlock dashboard assistant secret must differ from the session secret');
1385
2369
  }
2370
+ const previousSecrets = [...(assistantOptions?.previousSecrets ?? [])];
2371
+ if (previousSecrets.length > SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS) {
2372
+ throw new Error(`Slotlock dashboard assistant takes at most ${String(SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS)} previous secrets`);
2373
+ }
2374
+ for (const previous of previousSecrets) {
2375
+ if (Buffer.byteLength(previous, 'utf8') < 32) {
2376
+ throw new Error('Slotlock dashboard assistant previous secrets must each be at least 32 bytes');
2377
+ }
2378
+ if (previous === assistantOptions?.secret || previous === options.sessionSecret) {
2379
+ throw new Error('Slotlock dashboard assistant previous secrets must differ from the assistant and session secrets');
2380
+ }
2381
+ }
2382
+ if (new Set(previousSecrets).size !== previousSecrets.length) {
2383
+ throw new Error('Slotlock dashboard assistant previous secrets must differ from each other');
2384
+ }
2385
+ /** The current secret, then the previous ones: a stored key is tried with each in turn. */
2386
+ const assistantSecrets = assistantOptions ? [assistantOptions.secret, ...previousSecrets] : [];
1386
2387
  const memberMessages = createSlotlockRateLimiter(assistantOptions?.rate ?? { perSecond: 10 / 60, burst: 10 }, now);
1387
2388
  const toolTitles = new Map(slotlockAgentTools().map((tool) => [tool.name, tool.title]));
1388
2389
  /** The writes that take an idempotency key: the dashboard sets it, from the action's id. */
@@ -1407,7 +2408,7 @@ ${entries.length === 0
1407
2408
  * Who the assistant acts as, for the record and for ownership: the member by GitHub user id,
1408
2409
  * which a rename does not change. Shown as "Assistant for @login".
1409
2410
  */
1410
- const assistantActor = (session) => `dashboard-assistant:github:${session.uid}`;
2411
+ const assistantActor = (session) => `dashboard-assistant:${personOf(session)}`;
1411
2412
  const CONFIRMED_WRITES = new Set([
1412
2413
  'slotlock_create_event',
1413
2414
  'slotlock_update_event',
@@ -1441,6 +2442,15 @@ ${entries.length === 0
1441
2442
  else if (operation === 'slotlock_release_slots') {
1442
2443
  text = `Release the hold ${String(input.hold_id)} and every slot held with it.`;
1443
2444
  }
2445
+ else if (operation === 'slotlock_confirm_slot') {
2446
+ text = `Book the time held by ${String(input.hold_id)}${input.title ? `, as "${String(input.title)}"` : ''}, and release the other holds made with it.`;
2447
+ }
2448
+ else if (operation === 'slotlock_reschedule') {
2449
+ text = `Move the event ${String(input.event_id)} to ${String(input.starts_at)} to ${String(input.ends_at)}${input.resource_id ? ` on resource ${String(input.resource_id)}` : ''}.`;
2450
+ }
2451
+ else if (operation === 'slotlock_cancel') {
2452
+ text = `Cancel the event ${String(input.event_id)}.`;
2453
+ }
1444
2454
  else {
1445
2455
  text = '';
1446
2456
  }
@@ -1448,7 +2458,8 @@ ${entries.length === 0
1448
2458
  return text.length > 500 ? `${text.slice(0, 500)}…` : text;
1449
2459
  }
1450
2460
  /** Owners of the organization in use may change its assistant; a person always may their own. */
1451
- const isAdmin = (session) => !served || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
2461
+ /** An owner of the workspace: the person in their own, an organization's owner in its. */
2462
+ const isAdmin = (session) => session.org === undefined || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
1452
2463
  const assistantPrincipal = (session) => ({
1453
2464
  subject: assistantActor(session),
1454
2465
  tenantRef: tenantOf(session),
@@ -1486,6 +2497,8 @@ ${entries.length === 0
1486
2497
  ? { consumeRateLimit: assistant.consumeRateLimit }
1487
2498
  : {}),
1488
2499
  ...(confirm ? { confirm } : {}),
2500
+ // A proposal halts at `confirm` and never reaches the backend, so only what runs counts.
2501
+ ...(assistant.onUsage ? { onUsage: assistant.onUsage } : {}),
1489
2502
  onError: (error) => {
1490
2503
  try {
1491
2504
  ignoreRejection(options.onError?.(error));
@@ -1510,49 +2523,153 @@ ${entries.length === 0
1510
2523
  }
1511
2524
  let apiKey = null;
1512
2525
  if (settings.sealedKey !== null) {
1513
- apiKey = await openSlotlockAssistantKey(assistantOptions.secret, tenantOf(session), settings.sealedKey);
1514
- if (apiKey === null) {
2526
+ const tenantRef = tenantOf(session);
2527
+ const opened = await openSlotlockAssistantKeyWithAny(assistantSecrets, tenantRef, settings.sealedKey);
2528
+ if (opened === null) {
1515
2529
  return {
1516
2530
  problem: 'The stored API key cannot be opened: the server’s assistant secret changed. An admin can enter the key again in Settings.',
1517
2531
  };
1518
2532
  }
2533
+ apiKey = opened.apiKey;
2534
+ // Sealed under a previous secret: sealed again under the current one, unless an owner saved
2535
+ // another key meanwhile. Best effort: the key opened either way.
2536
+ if (opened.secretIndex > 0 && store?.resealKey) {
2537
+ try {
2538
+ const resealed = await sealSlotlockAssistantKey(assistantOptions.secret, tenantRef, apiKey);
2539
+ await store.resealKey(tenantRef, settings.sealedKey, resealed);
2540
+ }
2541
+ catch (error) {
2542
+ // A suspended tenant reads and writes nothing, this included: it opens the old way.
2543
+ if (error?.code !== 'tenant_suspended') {
2544
+ ignoreRejection(options.onError?.(error));
2545
+ }
2546
+ }
2547
+ }
1519
2548
  }
1520
2549
  return {
1521
2550
  model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
1522
2551
  };
1523
2552
  }
2553
+ /** When a change happens, in its resource's time zone when the card knows it, else UTC. */
2554
+ function whenOf(start, end, zone) {
2555
+ if (typeof start !== 'string')
2556
+ return null;
2557
+ const from = new Date(start);
2558
+ if (Number.isNaN(from.getTime()))
2559
+ return null;
2560
+ const timeZone = zone ?? 'UTC';
2561
+ let day;
2562
+ let time;
2563
+ try {
2564
+ day = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone });
2565
+ time = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone });
2566
+ }
2567
+ catch {
2568
+ return null;
2569
+ }
2570
+ const to = typeof end === 'string' ? new Date(end) : null;
2571
+ const span = to && !Number.isNaN(to.getTime())
2572
+ ? day.format(from) === day.format(to)
2573
+ ? `${time.format(from)}–${time.format(to)}`
2574
+ : `${time.format(from)} – ${day.format(to)}, ${time.format(to)}`
2575
+ : time.format(from);
2576
+ return `${day.format(from)}, ${span} ${timeZone}`;
2577
+ }
2578
+ /** What a proposed change's fields are called on its card. */
2579
+ const FACT_NAMES = Object.freeze({
2580
+ resource_id: 'Resource',
2581
+ resource_ids: 'Resources',
2582
+ pool_id: 'Pool',
2583
+ title: 'Title',
2584
+ external_ref: 'Reference',
2585
+ event_id: 'Booking',
2586
+ hold_id: 'Hold',
2587
+ ttl_seconds: 'Held for',
2588
+ notes: 'Note',
2589
+ description: 'Note',
2590
+ metadata: 'Details',
2591
+ slots: 'Times',
2592
+ });
1524
2593
  /**
1525
- * A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the
1526
- * proposal is bounded instead). A resource is named, with its id beside the name.
2594
+ * A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the proposal
2595
+ * is bounded instead). A resource is named; its id is the name's title.
1527
2596
  */
1528
2597
  function fieldValue(name, value, names) {
1529
- const named = (id) => (names.has(id) ? `${names.get(id)} (${id})` : id);
1530
- if (typeof value === 'string') {
1531
- return html `<span class="wrap">${name === 'resource_id' ? named(value) : value}</span>`;
2598
+ const named = (id) => names.has(id) ? html `<span title="${id}">${names.get(id)}</span>` : html `<code>${id}</code>`;
2599
+ if (name === 'resource_id' && typeof value === 'string')
2600
+ return named(value);
2601
+ if (name === 'resource_ids' && Array.isArray(value) && value.every((item) => typeof item === 'string')) {
2602
+ return html `${value.map((id, index) => html `${index > 0 ? ', ' : ''}${named(id)}`)}`;
2603
+ }
2604
+ if (name === 'ttl_seconds' && typeof value === 'number') {
2605
+ return html `${value >= 60 ? `${Math.round(value / 60)} min` : `${value} s`}`;
1532
2606
  }
2607
+ if ((name === 'event_id' || name === 'hold_id') && typeof value === 'string')
2608
+ return html `<code>${value}</code>`;
2609
+ if (typeof value === 'string')
2610
+ return html `<span class="wrap">${value}</span>`;
1533
2611
  if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
1534
- return html `<span class="wrap">${value.map((item) => (name === 'resource_ids' ? named(item) : item)).join(', ')}</span>`;
2612
+ return html `<span class="wrap">${value.join(', ')}</span>`;
1535
2613
  }
1536
- if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
2614
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null)
1537
2615
  return html `${String(value)}`;
1538
- }
1539
- return html `<pre class="wrap">${JSON.stringify(value, null, 2)}</pre>`;
2616
+ return html `<pre>${JSON.stringify(value, null, 2)}</pre>`;
1540
2617
  }
1541
- function actionCard(session, action, names) {
2618
+ /** Why a change or a step was refused, in words, for the codes a member meets. */
2619
+ const REFUSALS = Object.freeze({
2620
+ overlap: 'that time was just taken',
2621
+ pool_full: 'the pool is full then',
2622
+ not_found: 'it no longer exists',
2623
+ resource_not_found: 'the resource no longer exists',
2624
+ event_not_found: 'the booking no longer exists',
2625
+ invalid_input: 'the request was incomplete',
2626
+ invalid_arguments: 'the request was incomplete',
2627
+ validation_error: 'the request was incomplete',
2628
+ turn_read_budget_exceeded: 'one message may read only so much',
2629
+ too_many_pending_changes: 'too many changes are waiting for you',
2630
+ change_too_large_to_show: 'the change was too large to show',
2631
+ tool_not_available: 'the assistant may not do that',
2632
+ too_many_calls: 'too many at once',
2633
+ quota_exceeded: "this month's allowance is used up",
2634
+ approval_pending: 'it is waiting for an approval',
2635
+ });
2636
+ const refusal = (code) => REFUSALS[code] ?? code.replaceAll('_', ' ');
2637
+ function actionCard(session, action, names, zones) {
2638
+ const verb = slotlockAssistantActionVerb(action.operation);
2639
+ const title = action.summary || `${verb.verb}: ${toolTitles.get(action.operation) ?? action.operation}`;
2640
+ if (action.status !== 'pending') {
2641
+ const [tone, word] = action.status === 'done'
2642
+ ? ['ok', verb.done]
2643
+ : action.status === 'dismissed'
2644
+ ? ['', 'Declined']
2645
+ : action.status === 'approved'
2646
+ ? ['', 'Running…']
2647
+ : ['bad', `Refused: ${refusal(action.outcome ?? 'failed')}`];
2648
+ return html `<li class="turn action" id="action-${action.id}"><p class="receipt ${tone}"><span>${title}</span><span class="why">${word}</span></p></li>`;
2649
+ }
1542
2650
  const token = csrfToken(session);
1543
- const fields = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
1544
- const summary = action.summary ? html `<p class="action-summary">${action.summary}</p>` : html ``;
1545
- const state = {
1546
- pending: { text: 'Waiting for you', tone: 'held' },
1547
- approved: { text: 'Running', tone: 'held' },
1548
- dismissed: { text: 'Dismissed', tone: 'revoked' },
1549
- done: { text: 'Done', tone: 'active' },
1550
- failed: { text: 'Failed', tone: 'revoked' },
1551
- }[action.status] ?? { text: action.status, tone: 'revoked' };
1552
- const buttons = action.status === 'pending'
1553
- ? html `<div class="action-buttons"><form class="inline" method="post" action="${root}/assistant/approve"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button class="primary" type="submit">Approve</button></form><form class="inline" method="post" action="${root}/assistant/dismiss"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button type="submit">Dismiss</button></form></div>`
1554
- : html ``;
1555
- return html `<li class="action-card" id="action-${action.id}"><div class="action-head"><span class="title">${toolTitles.get(action.operation) ?? action.operation}</span><span class="pill ${state.tone}">${state.text}</span></div>${summary}<dl class="facts">${fields.map(([name, value]) => html `<dt>${name.replaceAll('_', ' ')}</dt><dd>${fieldValue(name, value, names)}</dd>`)}</dl>${action.status === 'failed' && action.outcome ? html `<p class="fine">Refused: <code>${action.outcome}</code></p>` : html ``}${buttons}</li>`;
2651
+ const input = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
2652
+ // The change's own time zone when it gives one, else its resource's.
2653
+ const zone = typeof action.input.timezone === 'string'
2654
+ ? action.input.timezone
2655
+ : typeof action.input.resource_id === 'string'
2656
+ ? zones.get(action.input.resource_id)
2657
+ : undefined;
2658
+ const when = whenOf(action.input.starts_at, action.input.ends_at, zone);
2659
+ const facts = [];
2660
+ const more = [];
2661
+ if (when)
2662
+ facts.push(html `<dt>When</dt><dd>${when}</dd>`);
2663
+ for (const [name, value] of input) {
2664
+ if (when && (name === 'starts_at' || name === 'ends_at' || name === 'timezone'))
2665
+ continue;
2666
+ const label = FACT_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1).replaceAll('_', ' ');
2667
+ (name in FACT_NAMES || name === 'attendees' ? facts : more).push(html `<dt>${label}</dt><dd>${fieldValue(name, value, names)}</dd>`);
2668
+ }
2669
+ const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${id}">`;
2670
+ return html `<li class="turn action" id="action-${action.id}"><div class="change"><p class="change-title">${title}</p>${facts.length > 0 ? html `<dl class="facts">${facts}</dl>` : html ``}${more.length > 0
2671
+ ? html `<details class="more"><summary>All details</summary><dl class="facts">${more}</dl></details>`
2672
+ : html ``}<div class="change-buttons"><form method="post" action="${root}/assistant/approve">${hidden(action.id)}<button class="${verb.danger ? 'danger' : 'primary'}" type="submit" data-busy="${verb.busy}">${verb.verb}</button></form><form method="post" action="${root}/assistant/dismiss">${hidden(action.id)}<button class="ghost" type="submit">Decline</button></form></div></div></li>`;
1556
2673
  }
1557
2674
  function readAction(id, value) {
1558
2675
  if (!isRecord(value) || typeof value.operation !== 'string')
@@ -1566,6 +2683,100 @@ ${entries.length === 0
1566
2683
  ...(typeof value.outcome === 'string' ? { outcome: value.outcome } : {}),
1567
2684
  };
1568
2685
  }
2686
+ /** The code a tool result was refused with, from the JSON the model was given. */
2687
+ function refusedCode(content) {
2688
+ try {
2689
+ const parsed = JSON.parse(content);
2690
+ const error = isRecord(parsed) && isRecord(parsed.error) ? parsed.error : null;
2691
+ return error && typeof error.code === 'string' ? error.code : null;
2692
+ }
2693
+ catch {
2694
+ return null;
2695
+ }
2696
+ }
2697
+ /** Notes the turn ends with that are not a failure: a member's Stop, and the step bound. */
2698
+ const quietNote = (text) => text === 'Stopped.' || text.startsWith('The assistant stopped after');
2699
+ /**
2700
+ * The conversation as the thread shows it: the member's messages as bubbles, the replies as
2701
+ * rendered Markdown, each run of tool steps folded into one line, proposed changes as cards, and
2702
+ * a failure that ends it with a way to send the last message again.
2703
+ */
2704
+ function assistantItems(session, entries, actions, names, zones) {
2705
+ const items = [];
2706
+ let steps = [];
2707
+ let cards = [];
2708
+ const flush = () => {
2709
+ if (steps.length > 0) {
2710
+ const bad = steps.some((step) => !step.ok);
2711
+ const seen = [];
2712
+ for (const step of steps)
2713
+ if (!seen.includes(step.label))
2714
+ seen.push(step.label);
2715
+ const summary = seen.map((label, index) => (index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1))).join(', ');
2716
+ items.push(html `<li class="turn steps"><details class="steps${bad ? ' has-bad' : ''}"><summary>${summary}</summary><ul class="step-list">${steps.map((step) => html `<li class="step ${step.ok ? 'ok' : 'bad'}">${step.label}${step.why ? html ` <span class="why">${step.why}</span>` : html ``}</li>`)}</ul></details></li>`);
2717
+ steps = [];
2718
+ }
2719
+ items.push(...cards);
2720
+ cards = [];
2721
+ };
2722
+ const lastUser = [...entries].reverse().find((entry) => entry.role === 'user');
2723
+ for (const [index, entry] of entries.entries()) {
2724
+ if (entry.role === 'user') {
2725
+ flush();
2726
+ items.push(html `<li class="turn mine"><div class="bubble">${entry.text}</div></li>`);
2727
+ }
2728
+ else if (entry.role === 'assistant') {
2729
+ if (entry.text.trim() === '')
2730
+ continue;
2731
+ flush();
2732
+ items.push(html `<li class="turn bot"><div class="prose">${new Html(renderSlotlockMarkdown(entry.text))}</div></li>`);
2733
+ }
2734
+ else if (entry.role === 'tool') {
2735
+ for (const result of entry.results) {
2736
+ const write = SLOTLOCK_ASSISTANT_TOOL_USE[result.name] === 'propose';
2737
+ const action = result.action ? readAction(result.action, actions[result.action]) : null;
2738
+ if (action) {
2739
+ cards.push(actionCard(session, action, names, zones));
2740
+ continue;
2741
+ }
2742
+ const title = toolTitles.get(result.name) ?? result.name;
2743
+ const code = result.error ? refusedCode(result.content) : null;
2744
+ steps.push({
2745
+ label: slotlockAssistantStepLabel(result.name, title, result.error ? 'bad' : 'ok', write),
2746
+ ok: !result.error,
2747
+ why: code ? refusal(code) : null,
2748
+ });
2749
+ }
2750
+ }
2751
+ else if (entry.role === 'note') {
2752
+ flush();
2753
+ const last = index === entries.length - 1;
2754
+ if (last && !quietNote(entry.text) && lastUser?.role === 'user') {
2755
+ items.push(html `<li class="turn error" role="alert"><span>${entry.text}</span><form method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="message" value="${lastUser.text}"><button class="text-button" type="submit">Retry</button></form></li>`);
2756
+ }
2757
+ else {
2758
+ items.push(html `<li class="turn note">${entry.text}</li>`);
2759
+ }
2760
+ }
2761
+ }
2762
+ flush();
2763
+ return items;
2764
+ }
2765
+ /** The tenant's resources as a proposed change's card names them, and each one's time zone. */
2766
+ async function resourceLookups(tenantRef) {
2767
+ const resources = await options.resources.list(tenantRef);
2768
+ return {
2769
+ resources,
2770
+ names: new Map(resources.map((resource) => [resource.id, resourceName(resource, resource.id)])),
2771
+ zones: new Map(resources.flatMap((resource) => (resource.timezone ? [[resource.id, resource.timezone]] : []))),
2772
+ };
2773
+ }
2774
+ const ASSISTANT_ICONS = {
2775
+ send: new Html('<svg class="arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13V3M3.5 7.5 8 3l4.5 4.5"/></svg>'),
2776
+ stop: new Html('<svg class="stop" viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor"/></svg>'),
2777
+ down: new Html('<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v10M3.5 8.5 8 13l4.5-4.5"/></svg>'),
2778
+ plus: new Html('<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>'),
2779
+ };
1569
2780
  async function assistantPage(session, status = 200, notice) {
1570
2781
  const store = assistantStore();
1571
2782
  if (!store)
@@ -1575,83 +2786,100 @@ ${entries.length === 0
1575
2786
  store.getSettings(tenantRef),
1576
2787
  store.getConversation(tenantRef, personOf(session)),
1577
2788
  ]);
2789
+ if (!settings) {
2790
+ const content = html `${noticeOf(notice, status)}${pageHead('Assistant')}${emptyState(ICONS.assistant, isAdmin(session) ? 'Connect a model to use the assistant.' : 'An admin of this organization connects a model in Settings.', isAdmin(session) ? { href: `${root}/settings#assistant`, label: 'Connect a model' } : undefined)}`;
2791
+ return respond(status, layout('Assistant', content, session, 'assistant'));
2792
+ }
1578
2793
  const entries = readSlotlockAssistantEntries(conversation?.messages);
1579
2794
  const actions = conversation?.actions ?? {};
1580
- const names = new Map();
1581
- if (Object.keys(actions).length > 0) {
1582
- for (const resource of await options.resources.list(tenantRef)) {
1583
- names.set(resource.id, resourceName(resource, resource.id));
1584
- }
1585
- }
2795
+ const empty = entries.length === 0;
2796
+ const { resources, names, zones } = empty || Object.keys(actions).length > 0
2797
+ ? await resourceLookups(tenantRef)
2798
+ : { resources: [], names: new Map(), zones: new Map() };
1586
2799
  const token = csrfToken(session);
1587
- const items = [];
1588
- for (const entry of entries) {
1589
- if (entry.role === 'user')
1590
- items.push(html `<li class="msg mine"><p>${entry.text}</p></li>`);
1591
- else if (entry.role === 'assistant' && entry.text.trim() !== '') {
1592
- items.push(html `<li class="msg bot"><p>${entry.text}</p></li>`);
1593
- }
1594
- else if (entry.role === 'note')
1595
- items.push(html `<li class="notice">${entry.text}</li>`);
1596
- else if (entry.role === 'tool') {
1597
- for (const result of entry.results) {
1598
- const action = result.action ? readAction(result.action, actions[result.action]) : null;
1599
- if (action)
1600
- items.push(actionCard(session, action, names));
1601
- else {
1602
- items.push(html `<li class="tool-line">${result.error ? 'Could not run' : 'Read'}: ${toolTitles.get(result.name) ?? result.name}</li>`);
1603
- }
1604
- }
1605
- }
1606
- }
1607
- const composer = html `<form class="composer" method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="3" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="Find a free van tomorrow morning and book it for Ana"></textarea><button class="primary" type="submit">Send</button></form>`;
1608
- const reset = entries.length > 0
1609
- ? html `<form class="inline" method="post" action="${root}/assistant/reset"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">New conversation</button></form>`
1610
- : undefined;
1611
- const content = html `${noticeOf(notice)}
1612
- ${pageHead('Assistant', reset)}
1613
- ${settings
1614
- ? html `<section class="panel chat">${items.length === 0
1615
- ? html `<p class="empty">Ask about bookings and free time. Changes wait for your approval.</p>`
1616
- : html `<ol class="thread">${items}</ol>`}<span id="latest"></span>${composer}</section>`
1617
- : html `<p class="empty">${isAdmin(session)
1618
- ? html `No model is connected. <a href="${root}/settings#assistant">Connect one in Settings</a>.`
1619
- : 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
2800
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
2801
+ const first = resources.find((resource) => !resource.archivedAt);
2802
+ const named = first ? resourceName(first, first.id) : null;
2803
+ // A read chip asks at once; a write chip fills the box to finish (app.js), or asks (no script).
2804
+ const prompts = [
2805
+ { label: "What's on today", message: "What's booked today?" },
2806
+ { label: 'Find free time', message: named ? `When is ${named} free tomorrow morning?` : 'What is free tomorrow morning?' },
2807
+ { label: 'Book', message: named ? `Book ${named} tomorrow 9:00–10:00` : 'Book ', fill: true },
2808
+ { label: 'Move a booking', message: 'Move the next booking one hour later', fill: true },
2809
+ { label: 'Cancel a booking', message: 'Cancel the next booking', fill: true },
2810
+ ];
2811
+ const chips = html `<div class="assistant-start"><div class="chips">${prompts.map((prompt) => html `<form method="post" action="${root}/assistant">${hidden()}<button class="chip" type="submit" name="message" value="${prompt.message}"${prompt.fill ? html ` data-fill="${prompt.message}"` : html ``}>${prompt.label}</button></form>`)}</div></div>`;
2812
+ const composer = html `<div class="dock"><button class="jump" type="button" hidden aria-label="Jump to the latest message">${ASSISTANT_ICONS.down}</button><form class="composer" method="post" action="${root}/assistant">${hidden()}<label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="2" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="${empty ? 'Ask about bookings' : 'Reply…'}"></textarea><div class="composer-bar"><span class="count" hidden></span><button class="send" type="submit" aria-label="Send">${ASSISTANT_ICONS.send}${ASSISTANT_ICONS.stop}</button></div></form></div>`;
2813
+ const reset = html `<form class="inline new-chat" method="post" action="${root}/assistant/reset">${hidden()}<button class="ghost" type="submit">${ASSISTANT_ICONS.plus}New conversation</button></form>`;
2814
+ const flag = empty ? new Html(' data-empty') : html ``;
2815
+ const content = html `${noticeOf(notice, status)}<div class="assistant-page"${flag}>${pageHead('Assistant', reset)}<div class="assistant" data-assistant data-stream="${root}/assistant/stream" data-page="${root}/assistant"${flag}><ol class="thread">${assistantItems(session, entries, actions, names, zones)}</ol><span id="latest"></span>${chips}${composer}</div></div>`;
1620
2816
  return respond(status, layout('Assistant', content, session, 'assistant'));
1621
2817
  }
1622
- async function assistantSettingsPanel(session) {
2818
+ /** The named provider a provider and base URL are, or none (Custom). */
2819
+ const presetOf = (provider, baseUrl) => SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id !== 'custom' && preset.provider === provider && preset.baseUrl === baseUrl);
2820
+ /**
2821
+ * The assistant's settings: what is connected and whether it answers (Test, its result in place),
2822
+ * then the form, one provider chosen from tiles, and Disconnect apart. A member sees only what is
2823
+ * connected, and where their messages go.
2824
+ */
2825
+ async function assistantSettingsSection(session, status, notice, draft) {
1623
2826
  const store = assistantStore();
1624
- if (!store)
1625
- return html ``;
1626
2827
  const settings = await store.getSettings(tenantOf(session));
1627
- const sentTo = html `<p class="fine">Members' messages, and the bookings and resources the assistant reads for them, are sent to this provider.</p>`;
1628
- if (!isAdmin(session)) {
1629
- return html `<section class="panel" id="assistant"><h2>Assistant</h2>${settings
1630
- ? html `<dl class="facts"><dt>Provider</dt><dd>${settings.provider === 'anthropic' ? 'Anthropic' : 'OpenAI-compatible'}</dd><dt>Model</dt><dd>${settings.model}</dd></dl>${sentTo}`
1631
- : html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
1632
- }
1633
2828
  const token = csrfToken(session);
1634
- const selected = (provider) => (settings?.provider === provider ? html ` selected` : html ``);
1635
- return html `<section class="panel" id="assistant">
1636
- <h2>Assistant</h2>
1637
- <form method="post" action="${root}/settings/assistant">
1638
- <input type="hidden" name="csrf" value="${token}">
1639
- <input type="hidden" name="once" value="${formNonce(session)}">
1640
- <div class="grid-form">
1641
- <label>Provider<select name="provider"><option value="anthropic"${selected('anthropic')}>Anthropic Messages API</option><option value="openai"${selected('openai')}>OpenAI-compatible</option></select></label>
1642
- <label>Base URL<input name="base_url" required maxlength="2048" value="${settings?.baseUrl ?? ''}" placeholder="https://api.anthropic.com"></label>
1643
- <label>Model<input name="model" required maxlength="200" value="${settings?.model ?? ''}" placeholder="your-model"></label>
1644
- <label>API key<input name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="${settings?.keyHint ? 'Leave empty to keep it' : 'Optional for a local model'}"></label>
1645
- </div>
1646
- <p class="fine">Key: ${settings?.keyHint ? html `set, ending in <code>${settings.keyHint}</code>` : 'none'}. OpenAI-compatible covers OpenAI, OpenRouter, vLLM, Ollama and LM Studio; give the URL that ends in <code>/v1</code>.</p>
1647
- ${settings?.keyHint ? html `<label class="choice"><input type="checkbox" name="clear_key" value="1"> Remove the key</label>` : html ``}
1648
- <button class="primary" type="submit">Save</button>
1649
- </form>
1650
- ${sentTo}
1651
- ${settings
1652
- ? html `<div class="row-actions"><form class="inline" method="post" action="${root}/settings/assistant/test"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Test connection</button></form><details class="act danger"><summary>Disconnect</summary><div class="pop"><p>Members can no longer chat. The key is deleted.</p><form method="post" action="${root}/settings/assistant/remove"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button class="danger" type="submit">Disconnect</button></form></div></details></div>`
1653
- : html ``}
1654
- </section>`;
2829
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
2830
+ const savedName = settings ? (presetOf(settings.provider, settings.baseUrl)?.name ?? 'Custom') : null;
2831
+ // Where messages go, in words: the provider's name, or a custom one's host.
2832
+ const hostOf = (url) => {
2833
+ try {
2834
+ return new URL(url).host;
2835
+ }
2836
+ catch {
2837
+ return url;
2838
+ }
2839
+ };
2840
+ const savedTo = settings ? (savedName === 'Custom' ? hostOf(settings.baseUrl) : savedName) : null;
2841
+ const admin = isAdmin(session);
2842
+ const badge = !settings
2843
+ ? html `<span class="badge">Not set up</span>`
2844
+ : notice && status === 200 && notice.startsWith('Connected')
2845
+ ? html `<span class="badge ok">Connected</span>`
2846
+ : notice && status === 502
2847
+ ? html `<span class="badge bad">Failing</span>`
2848
+ : html `<span class="badge ok">Set up</span>`;
2849
+ const test = settings && admin
2850
+ ? html `<form method="post" action="${root}/settings/assistant/test">${hidden()}<button type="submit">Test</button></form>`
2851
+ : html ``;
2852
+ const statusCard = html `<div class="set-card status-card" id="assistant"><div class="status-head"><span class="mono-tile" aria-hidden="true">${savedName ? savedName.charAt(0) : '–'}</span><div class="status-name"><strong>${savedName ?? 'No model connected'}</strong>${settings ? html `<code>${settings.model}</code>` : html ``}</div>${badge}${test}</div>${noticeOf(notice, status)}</div>`;
2853
+ const sentTo = (name) => html `<p class="fine">Members' messages, and the bookings they ask about, are sent to ${name ?? 'the provider you choose'}.</p>`;
2854
+ if (!admin) {
2855
+ return html `${statusCard}${settings ? sentTo(savedTo) : html `<p class="fine">An admin of this organization connects a model here.</p>`}`;
2856
+ }
2857
+ // What the form shows: a draft (a preset, or a model list) over what is saved.
2858
+ const provider = draft?.provider ?? settings?.provider ?? 'anthropic';
2859
+ const baseUrl = draft ? draft.baseUrl : (settings?.baseUrl ?? '');
2860
+ const model = draft?.model ?? settings?.model ?? '';
2861
+ const chosen = settings || draft ? (presetOf(provider, baseUrl)?.id ?? 'custom') : 'anthropic';
2862
+ const chosenName = chosen === 'custom' ? (baseUrl ? hostOf(baseUrl) : null) : (SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id === chosen)?.name ?? null);
2863
+ const models = draft?.models ?? [];
2864
+ const tiles = html `<div class="tiles" role="radiogroup" aria-label="Provider">${SLOTLOCK_ASSISTANT_PRESETS.map((preset) => html `<label class="tile"><input type="radio" name="preset" value="${preset.id}"${preset.id === chosen ? html ` checked` : html ``}><span class="tile-mark" aria-hidden="true">${preset.name.charAt(0)}</span>${preset.name}</label>`)}</div>`;
2865
+ const format = (value, label) => html `<label><input type="radio" name="provider" value="${value}"${provider === value ? html ` checked` : html ``}>${label}</label>`;
2866
+ const keyField = settings?.keyHint
2867
+ ? html `<div class="key-set"><code>•••• ${settings.keyHint}</code><details><summary>Replace</summary><input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" aria-label="New API key"></details></div>`
2868
+ : html `<input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="Optional for a local model">`;
2869
+ const form = html `<form class="set-card provider-form" method="post" action="${root}/settings/assistant">${hidden()}<div class="row stack"><span class="row-label">Provider</span>${tiles}</div><div class="row custom-only"><span class="row-label">API format</span><div class="segmented">${format('anthropic', 'Anthropic Messages')}${format('openai', 'OpenAI-compatible')}</div></div><div class="row custom-only"><label class="row-label" for="base-url">Base URL</label><input id="base-url" class="mono" name="base_url" maxlength="2048" value="${baseUrl}" placeholder="https://host/v1"></div><div class="row"><label class="row-label" for="model">Model</label><div class="field-with-button"><input id="model" class="mono" name="model" required maxlength="200" value="${model}" placeholder="Model name"${models.length > 0 ? html ` list="assistant-models"` : html ``}><button type="submit" formaction="${root}/settings/assistant/models" formnovalidate>Load models</button></div>${models.length > 0
2870
+ ? html `<datalist id="assistant-models">${models.map((id) => html `<option value="${id}"></option>`)}</datalist>`
2871
+ : html ``}</div><div class="row"><label class="row-label" for="api-key">API key</label>${keyField}</div><div class="card-foot">${sentTo(chosenName)}<button class="primary" type="submit">Save</button></div></form>`;
2872
+ const disconnect = settings
2873
+ ? html `<div class="set-card"><div class="danger-row"><strong>Disconnect ${savedName}</strong><details class="act danger"><summary>Disconnect…</summary><div class="pop"><p>The key is deleted. Members can't use the assistant until a model is connected again.</p><form method="post" action="${root}/settings/assistant/remove">${hidden()}<button class="danger" type="submit">Disconnect</button></form></div></details></div></div>`
2874
+ : html ``;
2875
+ return html `${statusCard}${form}${disconnect}`;
2876
+ }
2877
+ /** The provider and base URL a form names: a named provider's own, or Custom's two fields. */
2878
+ function formEndpoint(form) {
2879
+ const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === form.get('preset'));
2880
+ if (preset && preset.id !== 'custom')
2881
+ return { provider: preset.provider, baseUrl: preset.baseUrl };
2882
+ return { provider: form.get('provider'), baseUrl: (form.get('base_url') ?? '').trim() };
1655
2883
  }
1656
2884
  const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
1657
2885
  async function saveAssistantSettings(session, form) {
@@ -1660,8 +2888,8 @@ ${settings
1660
2888
  return notFound(session);
1661
2889
  if (!isAdmin(session))
1662
2890
  return adminOnly(session);
1663
- const provider = form.get('provider');
1664
- const checkedUrl = readSlotlockAssistantBaseUrl(form.get('base_url') ?? '', { privateUrls });
2891
+ const { provider, baseUrl: givenUrl } = formEndpoint(form);
2892
+ const checkedUrl = readSlotlockAssistantBaseUrl(givenUrl, { privateUrls });
1665
2893
  const model = (form.get('model') ?? '').trim();
1666
2894
  const apiKey = (form.get('api_key') ?? '').trim();
1667
2895
  if (provider !== 'anthropic' && provider !== 'openai') {
@@ -1698,6 +2926,71 @@ ${settings
1698
2926
  }
1699
2927
  return redirect(303, `${root}/settings#assistant`);
1700
2928
  }
2929
+ /**
2930
+ * The provider's models for the Model field (Load models): the form's provider and base URL,
2931
+ * checked as saving checks them, with the key given, or the saved one when it is for that same
2932
+ * provider and URL. Saves nothing; counted as a message to the assistant.
2933
+ */
2934
+ async function loadAssistantModels(session, form) {
2935
+ const store = assistantStore();
2936
+ if (!store || !assistantOptions)
2937
+ return notFound(session);
2938
+ if (!isAdmin(session))
2939
+ return adminOnly(session);
2940
+ if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
2941
+ return tooManyMessages(session, settingsPage);
2942
+ }
2943
+ const { provider, baseUrl: givenUrl } = formEndpoint(form);
2944
+ if (provider !== 'anthropic' && provider !== 'openai') {
2945
+ return settingsPage(session, 400, 'Choose a provider from the list.');
2946
+ }
2947
+ const typedModel = (form.get('model') ?? '').trim();
2948
+ const draft = {
2949
+ provider,
2950
+ baseUrl: givenUrl,
2951
+ ...(typedModel !== '' ? { model: typedModel } : {}),
2952
+ };
2953
+ const checkedUrl = readSlotlockAssistantBaseUrl(draft.baseUrl, { privateUrls });
2954
+ if (!checkedUrl.ok) {
2955
+ return settingsPage(session, 400, checkedUrl.reason === 'private'
2956
+ ? PRIVATE_URL_REFUSAL
2957
+ : 'Give an https base URL (http only for localhost or 127.0.0.1), with no user name, query or #fragment.', draft);
2958
+ }
2959
+ const baseUrl = checkedUrl.url;
2960
+ if (namesOwnHost(baseUrl))
2961
+ return settingsPage(session, 400, OWN_HOST_REFUSAL, draft);
2962
+ if (!(await checkSlotlockAssistantHost(baseUrl, { privateUrls, resolve: resolveHost }))) {
2963
+ return settingsPage(session, 400, PRIVATE_URL_REFUSAL, draft);
2964
+ }
2965
+ const typedKey = (form.get('api_key') ?? '').trim();
2966
+ if (typedKey !== '' && !SLOTLOCK_ASSISTANT_API_KEY.test(typedKey)) {
2967
+ return settingsPage(session, 400, 'An API key is up to 4,096 characters without spaces.', draft);
2968
+ }
2969
+ // The saved key only for the provider and URL it was entered for, as a model call sends it.
2970
+ let apiKey = typedKey !== '' ? typedKey : null;
2971
+ if (apiKey === null) {
2972
+ const found = await assistantModel(session);
2973
+ if (found && !('problem' in found) && found.model.provider === provider && found.model.baseUrl === baseUrl) {
2974
+ apiKey = found.model.apiKey;
2975
+ }
2976
+ }
2977
+ try {
2978
+ const listed = await listSlotlockAssistantModels({
2979
+ provider,
2980
+ baseUrl,
2981
+ apiKey,
2982
+ fetch: assistantFetch,
2983
+ timeoutMs: assistantTimeout,
2984
+ });
2985
+ return settingsPage(session, 200, listed.length > 0
2986
+ ? `${listed.length.toLocaleString('en')} model${listed.length === 1 ? '' : 's'} listed: choose one for Model, then Save.${typedKey !== '' ? ' Enter the API key again when you save.' : ''}`
2987
+ : 'The provider listed no models. Type the model name.', { ...draft, baseUrl, models: listed });
2988
+ }
2989
+ catch (error) {
2990
+ report(error instanceof Error ? error.message : 'assistant model list failed');
2991
+ return settingsPage(session, 502, slotlockAssistantProviderMessage(error), { ...draft, baseUrl });
2992
+ }
2993
+ }
1701
2994
  async function removeAssistantSettings(session) {
1702
2995
  const store = assistantStore();
1703
2996
  if (!store)
@@ -1736,24 +3029,42 @@ ${settings
1736
3029
  return settingsPage(session, 502, slotlockAssistantProviderMessage(error));
1737
3030
  }
1738
3031
  }
1739
- async function sendToAssistant(session, form) {
3032
+ /**
3033
+ * One member message: check it, run the turn (streaming its text and steps to `onEvent` when
3034
+ * given, stopping when `signal` aborts), and keep the conversation. Both ways to send share it.
3035
+ */
3036
+ async function runAssistantMessage(session, form, hooks = {}) {
1740
3037
  const store = assistantStore();
1741
3038
  if (!store || !assistantOptions)
1742
- return notFound(session);
3039
+ return { ok: false, status: 404, message: 'Nothing is here.' };
1743
3040
  const text = (form.get('message') ?? '').trim();
1744
3041
  if (text === '' || text.length > SLOTLOCK_ASSISTANT_MAX_MESSAGE) {
1745
- return assistantPage(session, 400, `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`);
3042
+ return {
3043
+ ok: false,
3044
+ status: 400,
3045
+ message: `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`,
3046
+ };
1746
3047
  }
1747
3048
  if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
1748
- return tooManyMessages(session, assistantPage);
3049
+ return {
3050
+ ok: false,
3051
+ status: 429,
3052
+ message: 'You have sent the assistant a lot in the last minute. Wait a moment and try again.',
3053
+ };
1749
3054
  }
1750
3055
  const found = await assistantModel(session);
1751
3056
  if (found === null)
1752
- return assistantPage(session, 409, 'No model is connected.');
3057
+ return { ok: false, status: 409, message: 'No model is connected.' };
1753
3058
  if ('problem' in found)
1754
- return assistantPage(session, 409, found.problem);
3059
+ return { ok: false, status: 409, message: found.problem };
1755
3060
  const tenantRef = tenantOf(session);
1756
3061
  const member = personOf(session);
3062
+ try {
3063
+ ignoreRejection(assistantOptions?.onMessage?.(tenantRef));
3064
+ }
3065
+ catch {
3066
+ // Metering never changes an answer.
3067
+ }
1757
3068
  const stored = await store.getConversation(tenantRef, member);
1758
3069
  const entries = boundSlotlockAssistantEntries([
1759
3070
  ...readSlotlockAssistantEntries(stored?.messages),
@@ -1777,6 +3088,8 @@ ${settings
1777
3088
  timeoutMs: assistantTimeout,
1778
3089
  turnTimeoutMs: assistantTurnTimeout,
1779
3090
  ...(assistantOptions.maxSteps !== undefined ? { maxSteps: assistantOptions.maxSteps } : {}),
3091
+ ...(hooks.onEvent ? { onEvent: hooks.onEvent } : {}),
3092
+ ...(hooks.signal ? { signal: hooks.signal } : {}),
1780
3093
  read: async (operation, input) => {
1781
3094
  // A turn's reads share one budget of resource-days, so 8 steps of wide reads cannot each
1782
3095
  // scan a hundred resources over years.
@@ -1833,8 +3146,85 @@ ${settings
1833
3146
  added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
1834
3147
  keep: Object.keys(kept),
1835
3148
  }, conversationTtl);
3149
+ return { ok: true, messages, actions: kept };
3150
+ }
3151
+ /** The form's way (no script): run the turn, then show the conversation at its latest message. */
3152
+ async function sendToAssistant(session, form) {
3153
+ const run = await runAssistantMessage(session, form);
3154
+ if (!run.ok)
3155
+ return run.status === 404 ? notFound(session) : assistantPage(session, run.status, run.message);
1836
3156
  return redirect(303, `${root}/assistant#latest`);
1837
3157
  }
3158
+ /**
3159
+ * app.js's way: the same turn, answered as it happens, one JSON object a line. `text` carries a
3160
+ * piece of the reply, `step` a tool step as it starts and ends, and the last line is `done` (the
3161
+ * turn as the page renders it, which replaces what was streamed) or `error`, each with a fresh
3162
+ * single-use value for the next send. The member's Stop aborts the request, and the turn with it.
3163
+ */
3164
+ async function streamToAssistant(session, form, request) {
3165
+ const encoder = new TextEncoder();
3166
+ const pipe = new TransformStream();
3167
+ const writer = pipe.writable.getWriter();
3168
+ let open = true;
3169
+ const write = (event) => {
3170
+ if (!open)
3171
+ return;
3172
+ writer.write(encoder.encode(`${JSON.stringify(event)}\n`)).catch(() => {
3173
+ open = false;
3174
+ });
3175
+ };
3176
+ const label = (name, state) => slotlockAssistantStepLabel(name, toolTitles.get(name) ?? name, state, SLOTLOCK_ASSISTANT_TOOL_USE[name] === 'propose');
3177
+ const failed = (message) => write({
3178
+ t: 'error',
3179
+ html: html `<li class="turn error" role="alert"><span>${message}</span></li>`.value,
3180
+ once: formNonce(session),
3181
+ });
3182
+ const work = (async () => {
3183
+ try {
3184
+ const run = await runAssistantMessage(session, form, {
3185
+ signal: request.signal,
3186
+ onEvent: (event) => {
3187
+ if (event.type === 'text')
3188
+ write({ t: 'text', d: event.delta });
3189
+ else if (event.type === 'call')
3190
+ write({ t: 'step', id: event.id, s: 'run', label: label(event.name, 'run') });
3191
+ else
3192
+ write({ t: 'step', id: event.id, s: event.ok ? 'ok' : 'bad', label: label(event.name, event.ok ? 'ok' : 'bad') });
3193
+ },
3194
+ });
3195
+ if (!run.ok) {
3196
+ failed(run.message);
3197
+ return;
3198
+ }
3199
+ // The turn from the member's message on, as a reload would show it.
3200
+ const start = run.messages.map((entry) => entry.role).lastIndexOf('user');
3201
+ const turn = run.messages.slice(Math.max(0, start));
3202
+ const lookups = Object.keys(run.actions).length > 0
3203
+ ? await resourceLookups(tenantOf(session))
3204
+ : { names: new Map(), zones: new Map() };
3205
+ write({
3206
+ t: 'done',
3207
+ html: assistantItems(session, turn, run.actions, lookups.names, lookups.zones).join(''),
3208
+ once: formNonce(session),
3209
+ });
3210
+ }
3211
+ catch (error) {
3212
+ report(error instanceof Error ? error.message : 'assistant stream failed');
3213
+ failed('Something went wrong. Reload the page and try again.');
3214
+ }
3215
+ finally {
3216
+ await writer.close().catch(() => undefined);
3217
+ open = false;
3218
+ }
3219
+ })();
3220
+ ignoreRejection(work);
3221
+ const page = respond(200, '');
3222
+ const headers = new Headers(page.headers);
3223
+ headers.set('Content-Type', 'application/x-ndjson; charset=utf-8');
3224
+ // Proxies that buffer (nginx) send each line as it is written.
3225
+ headers.set('X-Accel-Buffering', 'no');
3226
+ return new Response(pipe.readable, { status: 200, headers });
3227
+ }
1838
3228
  async function settleAction(session, form, decision) {
1839
3229
  const store = assistantStore();
1840
3230
  if (!store || !assistantOptions)
@@ -1882,6 +3272,256 @@ ${settings
1882
3272
  await store.deleteConversation(tenantOf(session), personOf(session));
1883
3273
  return redirect(303, `${root}/assistant`);
1884
3274
  }
3275
+ /**
3276
+ * A workspace's plan as both the Usage page and the overview show it: the plan (or the default it
3277
+ * moves to), what it has against the limits, this month's use against the quotas, and what needs a
3278
+ * person: a failed payment, a quota used up or nearly, more than the coming plan allows.
3279
+ * `here` is the page asking, so an item's action points to the page that resolves it.
3280
+ */
3281
+ async function planStanding(session, here, given) {
3282
+ const active = plans;
3283
+ const billing = options.billing;
3284
+ const tenantRef = tenantOf(session);
3285
+ const [plan, planDefault, resourceCount, keys, webhooks] = await Promise.all([
3286
+ active.plan(tenantRef),
3287
+ active.planDefault ? active.planDefault() : Promise.resolve(null),
3288
+ // As the limit counts them: archived resources included.
3289
+ options.resources.count
3290
+ ? options.resources.count(tenantRef)
3291
+ : options.resources.list(tenantRef).then((listed) => listed.length),
3292
+ given ?? options.keys.list({ tenantRef }),
3293
+ options.resources.webhooks?.list(tenantRef),
3294
+ ]);
3295
+ const month = slotlockUsageMonthToDate(now());
3296
+ const used = options.usage && options.resources.usage
3297
+ ? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
3298
+ : null;
3299
+ const keyCount = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
3300
+ const admin = isAdmin(session);
3301
+ const paying = plan !== null && plan.plan !== 'free';
3302
+ const upcoming = !plan && planDefault && planDefault.appliesFrom.getTime() > now() ? planDefault : null;
3303
+ const limits = plan?.limits ?? upcoming?.limits;
3304
+ const quotas = options.quotas && plan ? SLOTLOCK_PLAN_QUOTAS[plan.plan] : null;
3305
+ const resets = formatDate(slotlockQuotaPeriod(now()).resetsAt);
3306
+ const counted = (value) => value.toLocaleString('en-GB');
3307
+ const usagePage = `${root}/plan`;
3308
+ const upgradeAt = here === 'usage' ? '#upgrade' : `${usagePage}#upgrade`;
3309
+ const canUpgrade = !paying && admin && Boolean(billing || active.contact);
3310
+ const upgradeHint = paying ? '' : admin ? ' Upgrade for more.' : ' An owner of this organization can upgrade.';
3311
+ const attention = [];
3312
+ // Whether anything is near or past a limit: the overview then offers the upgrade.
3313
+ let pressing = false;
3314
+ if (plan?.status === 'past_due') {
3315
+ attention.push(attentionItem('bad', `Your last payment failed. ${admin && billing && plan.stripeCustomer ? 'Update your payment method in Manage billing' : 'An owner updates the payment method'} to keep the ${PLAN_NAMES[plan.plan]} plan.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
3316
+ }
3317
+ if (quotas && used) {
3318
+ for (const metric of ['operations', 'bookings']) {
3319
+ const quota = quotas[metric];
3320
+ const stop = Math.floor((quota * quotas.stopPercent) / 100);
3321
+ const spent = used.totals[metric];
3322
+ const refused = metric === 'operations' ? 'every call but reading usage' : 'new bookings and holds';
3323
+ const upgrade = canUpgrade ? { href: upgradeAt, label: 'Upgrade' } : here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined;
3324
+ if (spent * 100 >= quota * 80)
3325
+ pressing = true;
3326
+ if (spent >= stop) {
3327
+ attention.push(attentionItem('bad', `This month's ${metric} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${upgradeHint}`, upgrade));
3328
+ }
3329
+ else if (spent >= quota) {
3330
+ const billed = metric === 'operations' && billing?.overage && plan?.plan === 'developer' && paying;
3331
+ attention.push(attentionItem('warn', `You have used this month's ${counted(quota)} ${metric}.${billed ? ` Those past it are billed at ${billing?.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.${billed ? '' : upgradeHint}`, billed ? undefined : upgrade));
3332
+ }
3333
+ else if (spent * 100 >= quota * 80) {
3334
+ attention.push(attentionItem('warn', `You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${metric}. They start again on ${resets}.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
3335
+ }
3336
+ }
3337
+ }
3338
+ if (upcoming) {
3339
+ // What the workspace has that its coming plan will not allow: nothing is removed; adding stops.
3340
+ const over = [
3341
+ ['resources', resourceCount, upcoming.limits.resources],
3342
+ ['active API keys', keyCount, upcoming.limits.apiKeys],
3343
+ ['webhooks', webhooks?.length ?? 0, upcoming.limits.webhookEndpoints],
3344
+ ].filter(([, have, allowed]) => have > allowed);
3345
+ if (over.length > 0)
3346
+ pressing = true;
3347
+ attention.push(attentionItem(over.length > 0 ? 'warn' : 'info', `From ${formatDate(upcoming.appliesFrom)}, this workspace is on ${PLAN_NAMES[upcoming.plan]}: ${String(upcoming.limits.resources)} resources, ${String(upcoming.limits.apiKeys)} API keys and ${String(upcoming.limits.webhookEndpoints)} webhooks.${over.length > 0
3348
+ ? ` You have ${over.map(([what, have]) => `${String(have)} ${what}`).join(', ')}: nothing is removed, but you cannot add more until you have fewer${paying || !admin ? '' : ', or upgrade'}.`
3349
+ : ''}`, canUpgrade && over.length > 0 ? { href: upgradeAt, label: 'See plans' } : undefined));
3350
+ }
3351
+ /** One use against its allowance: a bar, amber from 80%, red at the limit. */
3352
+ const meter = (label, value, limit) => {
3353
+ if (limit === undefined) {
3354
+ return html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
3355
+ }
3356
+ const tone = value >= limit ? ' bad' : value * 100 >= limit * 80 ? ' warn' : '';
3357
+ return html `<div class="meter${tone}"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}<span class="meta"> of ${counted(limit)}</span></span></div><progress value="${String(Math.min(value, limit))}" max="${String(limit)}" aria-label="${label}: ${counted(value)} of ${counted(limit)}"></progress></div>`;
3358
+ };
3359
+ const limitMeters = html `${meter('Resources', resourceCount, limits?.resources)}${meter('Active API keys', keyCount, limits?.apiKeys)}${webhooks ? meter('Webhooks', webhooks.length, limits?.webhookEndpoints) : html ``}`;
3360
+ // Under a "This month" heading, or beside the limits with the month in each label.
3361
+ const monthMeters = (suffix) => used
3362
+ ? html `${meter(`Operations${suffix}`, used.totals.operations, quotas?.operations)}${meter(`Bookings${suffix}`, used.totals.bookings, quotas?.bookings)}${meter(`Holds${suffix}`, used.totals.holds, undefined)}`
3363
+ : html ``;
3364
+ const name = plan
3365
+ ? PLAN_NAMES[plan.plan]
3366
+ : upcoming
3367
+ ? `${PLAN_NAMES[upcoming.plan]} from ${dayOf(upcoming.appliesFrom)}`
3368
+ : 'No plan';
3369
+ return {
3370
+ plan,
3371
+ upcoming,
3372
+ paying,
3373
+ admin,
3374
+ used,
3375
+ month,
3376
+ resets,
3377
+ keys,
3378
+ name,
3379
+ limitMeters,
3380
+ monthMeters,
3381
+ meters: html `${limitMeters}${monthMeters(' this month')}`,
3382
+ attention,
3383
+ canUpgrade,
3384
+ pressing,
3385
+ };
3386
+ }
3387
+ /** One thing that needs a person: a sentence, its tone, and what resolves it. */
3388
+ const attentionItem = (tone, text, action) => html `<li class="attention ${tone}"><span class="attention-text">${text}</span>${action ? html `<a class="attention-action" href="${action.href}">${action.label}</a>` : html ``}</li>`;
3389
+ /** The list of things that need a person, or nothing. */
3390
+ const attentionList = (items) => items.length > 0 ? html `<ul class="attention-list" aria-label="Needs attention">${items}</ul>` : html ``;
3391
+ async function planPage(session, status = 200, notice, thanks = false) {
3392
+ if (!plans)
3393
+ return notFound(session);
3394
+ const billing = options.billing;
3395
+ const counted = (value) => value.toLocaleString('en-GB');
3396
+ if (session.env === 'test') {
3397
+ // Its own use, counted apart from Live's and never billed.
3398
+ const range = slotlockUsageMonthToDate(now());
3399
+ const tested = options.usage && options.resources.usage
3400
+ ? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
3401
+ : null;
3402
+ const meter = (label, value) => html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
3403
+ return respond(status, layout('Usage', html `${pageHead('Usage')}${noticeOf(notice, status)}<p>The test environment is on your live plan's limits and is never billed. Switch to Live to see your plan, or to change it.</p>${tested
3404
+ ? html `<section class="panel" id="usage"><h2>This month, in Test</h2><div class="meters">${meter('Operations', tested.totals.operations)}${meter('Bookings', tested.totals.bookings)}${meter('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
3405
+ : html ``}`, session, 'plan'));
3406
+ }
3407
+ const standing = await planStanding(session, 'usage');
3408
+ const { plan, paying, admin, used, month, resets, keys } = standing;
3409
+ const token = csrfToken(session);
3410
+ const keyName = new Map(keys.map((key) => [key.id, key.name]));
3411
+ // An organization is offered Team (where billing sells it, or by asking); a person, Developer.
3412
+ const offered = session.org !== undefined && (billing ? Boolean(billing.teamPrices) : true) ? 'team' : 'developer';
3413
+ const contact = plans.contact ?? billing?.contact;
3414
+ const manage = billing && plan?.stripeCustomer && admin
3415
+ ? html `<form class="inline" method="post" action="${root}/plan/manage"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Manage billing</button></form>`
3416
+ : html ``;
3417
+ const renews = paying && plan?.currentPeriodEnd
3418
+ ? html `<span class="meta">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}</span>`
3419
+ : html ``;
3420
+ const planCard = html `<section class="panel plan-card" id="plan"><div class="plan-head"><h2>${standing.name}</h2>${renews}</div><div class="meters">${standing.limitMeters}</div>${used ? html `` : html `<p class="fine">This server does not count use yet.</p>`}</section>${used
3421
+ ? html `<section class="panel" id="month"><h2>This month</h2><div class="meters">${standing.monthMeters('')}</div><p class="fine">Since ${month.from} (UTC); the month starts again on ${resets}. An operation is a call that reached Slotlock, whatever it answered.</p></section>`
3422
+ : html ``}`;
3423
+ const byKey = used && used.keys.length > 0
3424
+ ? html `<section class="panel" id="usage"><h2>Used by</h2><div class="table"><table><thead><tr><th>Key</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Key">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${counted(row.operations)}</td><td data-label="Bookings">${counted(row.bookings)}</td><td data-label="Holds">${counted(row.holds)}</td></tr>`)}</tbody></table></div><p class="fine">Webhook deliveries ${counted(used.totals.webhook_deliveries)} · assistant messages ${counted(used.totals.assistant_messages)}.</p></section>`
3425
+ : html ``;
3426
+ const offeredLimits = SLOTLOCK_PLAN_LIMITS[offered];
3427
+ const offeredText = html `${String(offeredLimits.resources)} resources, ${String(offeredLimits.apiKeys)} API keys and ${String(offeredLimits.webhookEndpoints)} webhook endpoints${options.quotas
3428
+ ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS[offered].operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS[offered].bookings)} bookings a month${offered === 'team' ? ' shared by the organization' : ''}`
3429
+ : html ``}.`;
3430
+ let upgrade = html ``;
3431
+ if (!paying) {
3432
+ if (!admin) {
3433
+ upgrade = html `<p class="fine">An owner of this organization can change its plan.</p>`;
3434
+ }
3435
+ else if (billing) {
3436
+ const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
3437
+ upgrade = html `<section class="panel" id="upgrade">
3438
+ <h2>${PLAN_NAMES[offered]}</h2>
3439
+ <p>${offeredText}${offered === 'developer' && billing.overage && options.quotas
3440
+ ? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
3441
+ : ''}</p>
3442
+ <form method="post" action="${root}/plan/upgrade">
3443
+ <input type="hidden" name="csrf" value="${token}">
3444
+ <input type="hidden" name="once" value="${formNonce(session)}">
3445
+ <fieldset class="modes"><legend>Billed</legend>
3446
+ <label class="choice"><input type="radio" name="interval" value="month" checked> Monthly, ${offeredPrices.month} a month</label>
3447
+ <label class="choice"><input type="radio" name="interval" value="year"> Yearly, ${offeredPrices.year} a year</label>
3448
+ </fieldset>
3449
+ <button class="primary" type="submit">Upgrade to ${PLAN_NAMES[offered]}</button>
3450
+ </form>
3451
+ <p class="fine">Paid through Stripe, in your currency, with tax added where it applies. Cancel at any time.</p>
3452
+ </section>`;
3453
+ }
3454
+ else if (contact) {
3455
+ // No payment here yet: the plan is arranged by asking.
3456
+ upgrade = html `<section class="panel" id="upgrade"><h2>${PLAN_NAMES[offered]}</h2><p>${offeredText}</p><a class="button primary" href="${contact}">Talk to us about ${PLAN_NAMES[offered]}</a></section>`;
3457
+ }
3458
+ }
3459
+ const content = html `${thanks ? html `<p class="notice done" role="status">${notice ?? ''}</p>` : noticeOf(notice, status)}
3460
+ ${pageHead('Usage', manage)}
3461
+ ${attentionList(standing.attention)}
3462
+ ${planCard}
3463
+ ${byKey}
3464
+ ${upgrade}
3465
+ ${contact
3466
+ ? html `<section class="panel" id="custom"><h2>Custom</h2><p>Slotlock on your own servers, with support from the people who build it.</p><a class="button" href="${contact}">Talk to the founder</a></section>`
3467
+ : html ``}`;
3468
+ return respond(status, layout('Usage', content, session, 'plan'));
3469
+ }
3470
+ async function upgradePlan(session, form) {
3471
+ const billing = options.billing;
3472
+ if (!billing)
3473
+ return notFound(session);
3474
+ if (session.env === 'test')
3475
+ return planPage(session, 400, 'Plans are changed in Live.');
3476
+ if (!isAdmin(session))
3477
+ return planPage(session, 403, 'Only owners of the organization can change its plan.');
3478
+ const interval = form.get('interval') === 'year' ? 'year' : 'month';
3479
+ const tenantRef = tenantOf(session);
3480
+ const plan = await billing.plan(tenantRef);
3481
+ if (plan && plan.plan !== 'free') {
3482
+ return planPage(session, 409, `You are on the ${PLAN_NAMES[plan.plan]} plan already.`);
3483
+ }
3484
+ let url;
3485
+ try {
3486
+ url = await billing.checkout({
3487
+ tenantRef,
3488
+ interval,
3489
+ ...(session.org !== undefined && billing.teamPrices ? { plan: 'team' } : {}),
3490
+ customer: plan?.stripeCustomer ?? null,
3491
+ successUrl: `${publicRoot}/plan?checkout=done`,
3492
+ cancelUrl: `${publicRoot}/plan`,
3493
+ });
3494
+ }
3495
+ catch (error) {
3496
+ if (error?.code === 'subscription_exists') {
3497
+ return planPage(session, 409, 'You pay for a subscription already. Its plan shows here once Stripe confirms it; to change it, use Manage billing.');
3498
+ }
3499
+ ignoreRejection(options.onError?.(error));
3500
+ return planPage(session, 502, 'The payment page did not open. Try again in a minute.');
3501
+ }
3502
+ return redirect(303, url);
3503
+ }
3504
+ async function manageBilling(session) {
3505
+ const billing = options.billing;
3506
+ if (!billing)
3507
+ return notFound(session);
3508
+ if (session.env === 'test')
3509
+ return planPage(session, 400, 'Billing is managed in Live.');
3510
+ if (!isAdmin(session))
3511
+ return planPage(session, 403, 'Only owners of the organization can manage its billing.');
3512
+ const plan = await billing.plan(tenantOf(session));
3513
+ if (!plan?.stripeCustomer)
3514
+ return planPage(session, 409, 'There is no billing to manage yet.');
3515
+ let url;
3516
+ try {
3517
+ url = await billing.portal({ customer: plan.stripeCustomer, returnUrl: `${publicRoot}/plan` });
3518
+ }
3519
+ catch (error) {
3520
+ ignoreRejection(options.onError?.(error));
3521
+ return planPage(session, 502, 'Billing did not open. Try again in a minute.');
3522
+ }
3523
+ return redirect(303, url);
3524
+ }
1885
3525
  /** The page a form was sent from, to show what went wrong with it. */
1886
3526
  function pageFor(route) {
1887
3527
  if (route.startsWith('/keys'))
@@ -1890,12 +3530,21 @@ ${settings
1890
3530
  return webhooksPage;
1891
3531
  if (route.startsWith('/waitlist') && options.resources.waitlist)
1892
3532
  return waitlistPage;
3533
+ if (route.startsWith('/approvals') && options.resources.approvals)
3534
+ return approvalsPage;
3535
+ if ((route.startsWith('/locations') || route.startsWith('/groups')) && options.resources.grouping) {
3536
+ return locationsPage;
3537
+ }
1893
3538
  if (route.startsWith('/resources') || route === '/hours')
1894
3539
  return resourcesPage;
1895
3540
  if (route.startsWith('/assistant') && assistantStore())
1896
3541
  return assistantPage;
1897
3542
  if (route.startsWith('/settings'))
1898
3543
  return settingsPage;
3544
+ if (route.startsWith('/plan') && plans)
3545
+ return planPage;
3546
+ if (route.startsWith('/teams') && teams)
3547
+ return teamsPage;
1899
3548
  return overviewPage;
1900
3549
  }
1901
3550
  function hoursPage(session, state) {
@@ -1950,6 +3599,7 @@ ${state.unshown
1950
3599
  return resources.find((resource) => resource.id === id) ?? null;
1951
3600
  }
1952
3601
  const notFound = (session) => respond(404, layout('Not found', html `<p class="notice">That resource is not one of yours.</p>`, session));
3602
+ const nothingHere = (session) => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
1953
3603
  async function hoursEditor(session, url) {
1954
3604
  const id = url.searchParams.get('resource');
1955
3605
  if (id === null) {
@@ -2037,8 +3687,83 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2037
3687
  return respond(200, layout('Your new key', content, session, 'keys'));
2038
3688
  }
2039
3689
  /** Act in another of the session's organizations: the same session, sealed again. */
2040
- function switchOrganization(session, form) {
3690
+ function switchEnvironment(session, form) {
3691
+ const wanted = form.get('environment');
3692
+ if (wanted !== 'live' && wanted !== 'test') {
3693
+ return respond(400, layout('Refused', html `<p class="notice">Choose Live or Test.</p>`, session));
3694
+ }
3695
+ const { env: _, ...rest } = session;
3696
+ const sealed = seal('slotlock-dashboard-session-v1', wanted === 'test' ? { ...rest, env: 'test' } : rest);
3697
+ const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
3698
+ return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
3699
+ }
3700
+ /**
3701
+ * Teams on Cloud: the person's GitHub organizations, as read at sign-in, each on or off. An owner
3702
+ * turns one on or off; a member asks an owner; one that is on is a workspace to switch to.
3703
+ */
3704
+ async function teamsPage(session, status = 200, notice) {
3705
+ if (!teams)
3706
+ return nothingHere(session);
3707
+ const orgs = session.orgs ?? [];
3708
+ const enabled = orgs.length > 0 ? await teams.enabled(orgs.map((org) => org.id)) : new Set();
3709
+ enabledOf.set(session, enabled);
3710
+ const token = csrfToken(session);
3711
+ const hidden = (org) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="organization" value="${org.id}">`;
3712
+ const rows = orgs.map((org) => {
3713
+ const on = enabled.has(org.id);
3714
+ const owner = org.role === 'admin';
3715
+ const actions = on
3716
+ ? html `${org.id === session.org ? html `<span class="muted">In use</span>` : html `<form class="inline" method="post" action="${root}/organization">${hidden(org)}<button type="submit">Switch to it</button></form>`}${owner
3717
+ ? html `<details class="act danger"><summary>Turn off</summary><div class="pop"><p>Its keys stop at once and no member can open it. What it holds stays until an owner turns it on again or erases it.</p>
3718
+ <form method="post" action="${root}/teams/disable">${hidden(org)}<label for="confirm-${org.id}">Type ${org.login} to confirm</label><input id="confirm-${org.id}" name="confirm" autocomplete="off" required><button class="danger" type="submit">Turn off</button></form></div></details>`
3719
+ : html ``}`
3720
+ : owner
3721
+ ? html `<form class="inline" method="post" action="${root}/teams/enable">${hidden(org)}<button class="primary" type="submit">Turn on</button></form>`
3722
+ : html `<span class="muted">An owner can turn it on</span>`;
3723
+ return html `<tr><td data-label="Organization" class="strong">${org.login}</td><td data-label="You are">${owner ? 'Owner' : 'Member'}</td><td data-label="Slotlock"><span class="pill ${on ? 'active' : 'revoked'}">${on ? 'On' : 'Off'}</span></td><td class="actions">${actions}</td></tr>`;
3724
+ });
3725
+ const content = html `${noticeOf(notice, status)}${pageHead('Teams')}
3726
+ <p>Work with the people in a GitHub organization: once an owner turns Slotlock on for it, every owner and member shares its resources, keys and history, and picks it from the workspace menu. Your own workspace stays yours.</p>
3727
+ ${rows.length === 0
3728
+ ? emptyState(ICONS.teams, 'Slotlock sees none of your GitHub organizations. It reads them as you sign in: after joining one, sign out and in again. An organization that restricts OAuth apps needs an owner to approve this one.')
3729
+ : html `<div class="table"><table><thead><tr><th>Organization</th><th>You are</th><th>Slotlock</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>
3730
+ <p class="fine">Who owns an organization is read from GitHub as you sign in. Leaving one ends your access at your next sign-in.</p>`}`;
3731
+ return respond(status, layout('Teams', content, session, 'teams'));
3732
+ }
3733
+ async function changeTeam(session, form, on) {
3734
+ if (!teams)
3735
+ return nothingHere(session);
3736
+ const org = session.orgs?.find((candidate) => candidate.id === form.get('organization'));
3737
+ if (!org)
3738
+ return teamsPage(session, 400, 'That is not one of your organizations.');
3739
+ if (org.role !== 'admin')
3740
+ return teamsPage(session, 403, `Only an owner of ${org.login} can turn Slotlock ${on ? 'on' : 'off'} for it.`);
3741
+ // Who owns it was read as the person signed in: only a recent sign-in is trusted to say so.
3742
+ if (now() - (session.exp - SESSION_TTL_MS) > FRESH_SIGN_IN_MS) {
3743
+ return teamsPage(session, 403, `To turn Slotlock ${on ? 'on' : 'off'} for ${org.login}, sign out and in again: who owns it is read from GitHub as you sign in.`);
3744
+ }
3745
+ if (on) {
3746
+ await teams.enable({ id: org.id, login: org.login }, personOf(session));
3747
+ return teamsPage(session, 200, `Slotlock is on for ${org.login}. Its members pick it from the workspace menu.`);
3748
+ }
3749
+ if (form.get('confirm') !== org.login)
3750
+ return teamsPage(session, 400, `Type ${org.login} to turn it off.`);
3751
+ await teams.disable(org.id, personOf(session));
3752
+ return teamsPage(session, 200, `Slotlock is off for ${org.login}: its keys stopped. What it holds stays until it is turned on again or erased.`);
3753
+ }
3754
+ async function switchOrganization(session, form) {
2041
3755
  const wanted = form.get('organization') ?? '';
3756
+ if (teams) {
3757
+ // Their own tenant, or an organization of theirs that is turned on now.
3758
+ const org = session.orgs?.find((candidate) => candidate.id === wanted);
3759
+ if (wanted !== '' && (!org || !(await teams.enabled([wanted])).has(wanted))) {
3760
+ return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization. An owner turns it on from Teams.</p>`, session));
3761
+ }
3762
+ const { org: _, ...personal } = session;
3763
+ const sealed = seal('slotlock-dashboard-session-v1', wanted === '' ? personal : { ...personal, org: wanted });
3764
+ const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
3765
+ return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
3766
+ }
2042
3767
  const org = session.orgs?.find((candidate) => candidate.id === wanted && served?.has(candidate.id));
2043
3768
  if (!served || !org) {
2044
3769
  return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization.</p>`, session));
@@ -2059,7 +3784,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2059
3784
  code_challenge: challenge,
2060
3785
  code_challenge_method: 'S256',
2061
3786
  // Reading organization membership needs read:org; a personal tenant needs no scope at all.
2062
- ...(served ? { scope: 'read:org' } : {}),
3787
+ ...(readsOrganizations ? { scope: 'read:org' } : {}),
2063
3788
  }).toString();
2064
3789
  const sealed = seal('slotlock-dashboard-sign-in-v1', {
2065
3790
  state,
@@ -2165,7 +3890,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2165
3890
  continue;
2166
3891
  }
2167
3892
  const id = String(organization.id);
2168
- if (served?.has(id)) {
3893
+ if (teams || served?.has(id)) {
2169
3894
  found.set(id, { id, login: organization.login, role: membership.role === 'admin' ? 'admin' : 'member' });
2170
3895
  }
2171
3896
  }
@@ -2173,13 +3898,146 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2173
3898
  .sort((a, b) => a.login.localeCompare(b.login))
2174
3899
  .slice(0, MAX_SESSION_ORGANIZATIONS);
2175
3900
  }
3901
+ /** Google's sign-in (OpenID Connect, code flow with PKCE and a nonce), back to the same callback. */
3902
+ async function startGoogleSignIn(provider) {
3903
+ const state = randomBytes(32).toString('base64url');
3904
+ const verifier = randomBytes(32).toString('base64url');
3905
+ const nonce = randomBytes(24).toString('base64url');
3906
+ const target = new URL(provider.authorizeUrl ?? 'https://accounts.google.com/o/oauth2/v2/auth');
3907
+ target.search = new URLSearchParams({
3908
+ client_id: provider.clientId,
3909
+ redirect_uri: callbackUrl,
3910
+ response_type: 'code',
3911
+ scope: 'openid email',
3912
+ state,
3913
+ nonce,
3914
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'),
3915
+ code_challenge_method: 'S256',
3916
+ }).toString();
3917
+ const sealed = seal('slotlock-dashboard-sign-in-v1', {
3918
+ state,
3919
+ verifier,
3920
+ nonce,
3921
+ idp: 'google',
3922
+ exp: now() + SIGN_IN_TTL_MS,
3923
+ });
3924
+ return redirect(302, target.href, [setCookie(SIGN_IN_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
3925
+ }
3926
+ /** Google's signing keys by key id, kept for as long as its answer says (an hour by default). */
3927
+ let googleKeys = null;
3928
+ async function readGoogleKeys(provider, refresh) {
3929
+ if (googleKeys && !refresh && googleKeys.until > now())
3930
+ return googleKeys.keys;
3931
+ const response = await fetchImpl(provider.jwksUrl ?? 'https://www.googleapis.com/oauth2/v3/certs', {
3932
+ headers: { Accept: 'application/json' },
3933
+ redirect: 'manual',
3934
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
3935
+ });
3936
+ if (!response.ok)
3937
+ throw new Error(`Google keys answered HTTP ${response.status}`);
3938
+ const body = await response.json();
3939
+ const keys = new Map();
3940
+ for (const jwk of isRecord(body) && Array.isArray(body.keys) ? body.keys : []) {
3941
+ if (!isRecord(jwk) || jwk.kty !== 'RSA' || typeof jwk.kid !== 'string' || (jwk.alg !== undefined && jwk.alg !== 'RS256'))
3942
+ continue;
3943
+ const key = await globalThis.crypto.subtle
3944
+ .importKey('jwk', { kty: 'RSA', n: jwk.n, e: jwk.e, alg: 'RS256', ext: true }, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'])
3945
+ .catch(() => null);
3946
+ if (key)
3947
+ keys.set(jwk.kid, key);
3948
+ }
3949
+ const maxAge = Number(/max-age=(\d+)/.exec(response.headers.get('cache-control') ?? '')?.[1]);
3950
+ googleKeys = { keys, until: now() + (Number.isFinite(maxAge) && maxAge > 0 ? Math.min(maxAge * 1000, 86_400_000) : GOOGLE_KEYS_DEFAULT_TTL_MS) };
3951
+ return keys;
3952
+ }
3953
+ /** Whether an ID token's signature is Google's: RS256, by a key id Google publishes (read again once when unknown, as Google rotates them). */
3954
+ async function googleSigned(provider, header, payload, signature) {
3955
+ let parsed;
3956
+ try {
3957
+ parsed = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
3958
+ }
3959
+ catch {
3960
+ return false;
3961
+ }
3962
+ if (!isRecord(parsed) || parsed.alg !== 'RS256' || typeof parsed.kid !== 'string')
3963
+ return false;
3964
+ let key = (await readGoogleKeys(provider, false)).get(parsed.kid);
3965
+ if (!key)
3966
+ key = (await readGoogleKeys(provider, true)).get(parsed.kid);
3967
+ if (!key)
3968
+ return false;
3969
+ return globalThis.crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, Buffer.from(signature, 'base64url'), Buffer.from(`${header}.${payload}`, 'ascii'));
3970
+ }
3971
+ /**
3972
+ * The person Google signed in, from the ID token its token endpoint returned for this code. The
3973
+ * token's signature is checked against Google's published keys, and its issuer, audience (and
3974
+ * azp), expiry and nonce (OpenID Connect Core 1.0, 3.1.3.7).
3975
+ */
3976
+ async function askGoogle(provider, code, verifier, nonce) {
3977
+ try {
3978
+ const response = await fetchImpl(provider.tokenUrl ?? 'https://oauth2.googleapis.com/token', {
3979
+ method: 'POST',
3980
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
3981
+ body: new URLSearchParams({
3982
+ code,
3983
+ client_id: provider.clientId,
3984
+ client_secret: provider.clientSecret,
3985
+ redirect_uri: callbackUrl,
3986
+ grant_type: 'authorization_code',
3987
+ code_verifier: verifier,
3988
+ }).toString(),
3989
+ redirect: 'manual',
3990
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
3991
+ });
3992
+ if (!response.ok) {
3993
+ report(`Google token exchange failed: HTTP ${response.status}`);
3994
+ return null;
3995
+ }
3996
+ const body = await response.json().catch(() => null);
3997
+ const token = isRecord(body) && typeof body.id_token === 'string' ? body.id_token : null;
3998
+ const [header, payload, signature, ...extra] = token?.split('.') ?? [];
3999
+ if (!header || !payload || !signature || extra.length > 0 || ![header, payload, signature].every((part) => BASE64URL.test(part))) {
4000
+ return null;
4001
+ }
4002
+ // Signed by Google, with one of the keys it publishes (RS256), whichever way the token came.
4003
+ if (!(await googleSigned(provider, header, payload, signature))) {
4004
+ report('Google sign-in refused: the ID token is not signed by Google');
4005
+ return null;
4006
+ }
4007
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
4008
+ if (!isRecord(claims))
4009
+ return null;
4010
+ const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
4011
+ if ((claims.iss !== 'https://accounts.google.com' && claims.iss !== 'accounts.google.com') ||
4012
+ !audience.includes(provider.clientId) ||
4013
+ (audience.length > 1 && claims.azp !== provider.clientId) ||
4014
+ typeof claims.exp !== 'number' ||
4015
+ claims.exp * 1000 <= now() ||
4016
+ typeof claims.nonce !== 'string' ||
4017
+ !safeEqual(claims.nonce, nonce) ||
4018
+ typeof claims.sub !== 'string' ||
4019
+ !GOOGLE_SUBJECT.test(claims.sub)) {
4020
+ report('Google sign-in refused: the ID token did not check out');
4021
+ return null;
4022
+ }
4023
+ // The email names the person on the page only, and only once Google has verified it.
4024
+ const email = claims.email_verified === true && typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email)
4025
+ ? claims.email
4026
+ : 'Google account';
4027
+ return { uid: claims.sub, login: email, idp: 'google' };
4028
+ }
4029
+ catch (error) {
4030
+ report(`Google sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`);
4031
+ return null;
4032
+ }
4033
+ }
2176
4034
  async function askGitHub(code, verifier) {
2177
4035
  try {
2178
4036
  const token = await exchangeCode(code, verifier);
2179
4037
  if (token === null)
2180
4038
  return null;
2181
4039
  const user = await readGitHubUser(token);
2182
- if (!user || !served)
4040
+ if (!user || !readsOrganizations)
2183
4041
  return user;
2184
4042
  const orgs = await readMemberships(token);
2185
4043
  return orgs === null ? null : { ...user, orgs };
@@ -2221,7 +4079,12 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2221
4079
  if (!(await options.state.use('sign_in', pending.state, pending.exp))) {
2222
4080
  return signedOutPage(400, SIGN_IN_AGAIN, [clearSignIn]);
2223
4081
  }
2224
- user = await askGitHub(code, verifier);
4082
+ user =
4083
+ pending.idp === 'google'
4084
+ ? google && typeof pending.nonce === 'string'
4085
+ ? await askGoogle(google, code, verifier, pending.nonce)
4086
+ : null
4087
+ : await askGitHub(code, verifier);
2225
4088
  }
2226
4089
  finally {
2227
4090
  signInsInFlight -= 1;
@@ -2234,12 +4097,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2234
4097
  }
2235
4098
  }
2236
4099
  if (!user) {
2237
- return signedOutPage(502, 'GitHub did not complete the sign-in. Try again.', [clearSignIn]);
4100
+ return signedOutPage(502, `${pending.idp === 'google' ? 'Google' : 'GitHub'} did not complete the sign-in. Try again.`, [clearSignIn]);
2238
4101
  }
2239
- if (!allowed(user.uid)) {
2240
- return signedOutPage(403, `This Slotlock server does not let @${user.login} sign in.`, [
2241
- clearSignIn,
2242
- ]);
4102
+ if (!allowed(user.uid, user.idp)) {
4103
+ return signedOutPage(403, `This Slotlock server does not let ${user.idp === 'google' ? user.login : `@${user.login}`} sign in.`, [clearSignIn]);
2243
4104
  }
2244
4105
  const orgs = user.orgs ?? [];
2245
4106
  if (served && orgs.length === 0) {
@@ -2250,7 +4111,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2250
4111
  login: user.login,
2251
4112
  sid: randomBytes(24).toString('base64url'),
2252
4113
  exp: now() + SESSION_TTL_MS,
2253
- ...(served ? { orgs, org: orgs[0].id } : {}),
4114
+ ...(user.idp === 'google' ? { idp: 'google' } : {}),
4115
+ ...(served ? { orgs, org: orgs[0].id } : teams && user.idp !== 'google' ? { orgs } : {}),
2254
4116
  });
2255
4117
  return redirect(303, root, [
2256
4118
  clearSignIn,
@@ -2290,6 +4152,25 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2290
4152
  return keysPage(session, 400, 'Choose an access level and an expiry from the lists.');
2291
4153
  }
2292
4154
  const days = KEY_EXPIRY_DAYS[expiry];
4155
+ const limited = {
4156
+ locations: [...new Set(form.getAll('limit_location'))],
4157
+ groups: [...new Set(form.getAll('limit_group'))],
4158
+ };
4159
+ const resourceScope = limited.locations.length + limited.groups.length > 0 ? limited : undefined;
4160
+ const limits = keyLimitsOf(form);
4161
+ if (typeof limits === 'string')
4162
+ return keysPage(session, 400, limits);
4163
+ if (resourceScope) {
4164
+ if (scopes.includes('admin')) {
4165
+ return keysPage(session, 400, 'A key limited to locations or groups reads and writes only: choose that access.');
4166
+ }
4167
+ // Keys limited to part of the tenant come with Developer (RFC 0003 pricing); a server without
4168
+ // plans allows them.
4169
+ const plan = plans ? await plans.plan(tenantOf(session)) : null;
4170
+ if (plan?.plan === 'free') {
4171
+ return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Usage.');
4172
+ }
4173
+ }
2293
4174
  try {
2294
4175
  const created = await options.keys.create({
2295
4176
  tenantRef: tenantOf(session),
@@ -2297,6 +4178,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2297
4178
  scopes,
2298
4179
  createdBy: personOf(session),
2299
4180
  origin: 'dashboard',
4181
+ ...(resourceScope ? { resourceScope } : {}),
4182
+ ...(Object.keys(limits).length > 0 ? { limits } : {}),
2300
4183
  ...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
2301
4184
  });
2302
4185
  return shownOncePage(session, created.key, created.apiKey, false);
@@ -2306,8 +4189,13 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2306
4189
  if (code === 'invalid_api_key_name') {
2307
4190
  return keysPage(session, 400, 'Give the key a name of 1 to 100 characters.');
2308
4191
  }
4192
+ if (code === 'invalid_api_key_resource_scope') {
4193
+ return keysPage(session, 400, 'Choose up to 20 locations and 20 groups from the lists.');
4194
+ }
2309
4195
  if (code === 'api_key_limit_reached') {
2310
- return keysPage(session, 409, 'You have as many keys as Slotlock allows. Revoke one first.');
4196
+ return keysPage(session, 409, plans
4197
+ ? 'You have as many active keys as your plan allows. Revoke one first, or see Usage.'
4198
+ : 'You have as many keys as Slotlock allows. Revoke one first.');
2311
4199
  }
2312
4200
  throw error;
2313
4201
  }
@@ -2318,6 +4206,40 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2318
4206
  actor: personOf(session),
2319
4207
  origin: 'dashboard',
2320
4208
  });
4209
+ /** A key's limits from its form (RFC 0004 A5): each empty field the server's; else what is wrong. */
4210
+ function keyLimitsOf(form) {
4211
+ const whole = (name, most) => {
4212
+ const text = (form.get(name) ?? '').trim();
4213
+ if (text === '')
4214
+ return undefined;
4215
+ const value = /^[0-9]{1,7}$/.test(text) ? Number(text) : Number.NaN;
4216
+ return Number.isSafeInteger(value) && value >= 1 && value <= most ? value : null;
4217
+ };
4218
+ const maxHolds = whole('max_holds', 10_000);
4219
+ const maxHeldHours = whole('max_held_hours', 876_000);
4220
+ const requestsPerMinute = whole('requests_per_minute', 100_000);
4221
+ if (maxHolds === null || maxHeldHours === null || requestsPerMinute === null) {
4222
+ return 'Give each limit as a whole number: live holds 1 to 10,000, hours held 1 to 876,000, requests a minute 1 to 100,000. Leave one empty for the server’s.';
4223
+ }
4224
+ return {
4225
+ ...(maxHolds !== undefined ? { maxHolds } : {}),
4226
+ ...(maxHeldHours !== undefined ? { maxHeldHours } : {}),
4227
+ ...(requestsPerMinute !== undefined ? { requestsPerMinute } : {}),
4228
+ ...(form.get('requires_approval') === 'on' ? { requiresApproval: true } : {}),
4229
+ };
4230
+ }
4231
+ async function saveKeyLimits(session, form) {
4232
+ const id = form.get('id') ?? '';
4233
+ if (!UUID_PATTERN.test(id))
4234
+ return keysPage(session, 404, 'That key is not one of yours.');
4235
+ const limits = keyLimitsOf(form);
4236
+ if (typeof limits === 'string')
4237
+ return keysPage(session, 400, limits);
4238
+ if (!(await options.keys.setLimits({ tenantRef: tenantOf(session), id, limits }))) {
4239
+ return keysPage(session, 404, 'That key is not an active key of yours.');
4240
+ }
4241
+ return redirect(303, `${root}/keys`);
4242
+ }
2321
4243
  async function changeKey(session, form, change) {
2322
4244
  const id = form.get('id') ?? '';
2323
4245
  if (!UUID_PATTERN.test(id))
@@ -2345,7 +4267,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2345
4267
  catch (error) {
2346
4268
  const code = error.code;
2347
4269
  if (code === 'resource_limit_reached') {
2348
- return resourcesPage(session, 409, `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
4270
+ const limit = error.limit;
4271
+ return resourcesPage(session, 409, typeof limit === 'number' && limit < SLOTLOCK_DASHBOARD_MAX_RESOURCES
4272
+ ? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Usage.`
4273
+ : `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
2349
4274
  }
2350
4275
  if (code === 'invalid_timezone') {
2351
4276
  return resourcesPage(session, 400, `${timezone} is not a time zone Slotlock knows, such as Europe/London.`);
@@ -2365,7 +4290,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2365
4290
  const session = await readSession(request);
2366
4291
  if (!session)
2367
4292
  return redirect(303, root);
2368
- const form = await readForm(request, route === '/assistant' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
4293
+ const form = await readForm(request, route === '/assistant' || route === '/assistant/stream' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
2369
4294
  if (form === 'too_large') {
2370
4295
  return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
2371
4296
  }
@@ -2395,7 +4320,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2395
4320
  try {
2396
4321
  first =
2397
4322
  checked === 'ok' &&
2398
- (await options.state.use('form', once, session.exp, session.uid));
4323
+ (await options.state.use('form', once, session.exp, session.idp === 'google' ? personOf(session) : session.uid));
2399
4324
  }
2400
4325
  catch (error) {
2401
4326
  if (error.code !== 'dashboard_form_limit')
@@ -2403,9 +4328,16 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2403
4328
  return pageFor(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
2404
4329
  }
2405
4330
  if (!first) {
4331
+ // The first one was taken: a second click, a reload or a resend shows what it did.
4332
+ if (route.startsWith('/assistant'))
4333
+ return redirect(303, `${root}/assistant#latest`);
2406
4334
  return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
2407
4335
  }
2408
4336
  }
4337
+ // A tenant new to plans is put on the default plan before it adds what plans limit.
4338
+ if (route === '/keys' || route === '/resources' || route === '/webhooks') {
4339
+ await plans?.enrol?.(tenantOf(session));
4340
+ }
2409
4341
  switch (route) {
2410
4342
  case '/sign-out':
2411
4343
  await options.state.endSession(session.sid, session.exp);
@@ -2416,16 +4348,34 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2416
4348
  return changeKey(session, form, 'rotate');
2417
4349
  case '/keys/revoke':
2418
4350
  return changeKey(session, form, 'revoke');
4351
+ case '/keys/limits':
4352
+ return saveKeyLimits(session, form);
2419
4353
  case '/resources':
2420
4354
  return addResource(session, form);
2421
4355
  case '/hours':
2422
4356
  return saveHours(session, form);
2423
4357
  case '/organization':
2424
4358
  return switchOrganization(session, form);
4359
+ case '/environment':
4360
+ return switchEnvironment(session, form);
2425
4361
  case '/resources/rules':
2426
4362
  return saveRules(session, form);
2427
4363
  case '/resources/pool':
2428
4364
  return savePool(session, form);
4365
+ case '/resources/profile':
4366
+ return saveProfile(session, form);
4367
+ case '/resources/place':
4368
+ return savePlace(session, form);
4369
+ case '/resources/parent':
4370
+ return saveParent(session, form);
4371
+ case '/locations':
4372
+ return saveLocation(session, form);
4373
+ case '/locations/delete':
4374
+ return deleteGrouping(session, form, 'location');
4375
+ case '/groups':
4376
+ return saveGroup(session, form);
4377
+ case '/groups/delete':
4378
+ return deleteGrouping(session, form, 'group');
2429
4379
  case '/webhooks':
2430
4380
  return addWebhook(session, form);
2431
4381
  case '/webhooks/delete':
@@ -2434,8 +4384,18 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2434
4384
  return changeWebhook(session, form, 'enable');
2435
4385
  case '/waitlist/leave':
2436
4386
  return leaveWaitlist(session, form);
4387
+ case '/approvals/decide':
4388
+ return decideApproval(session, form);
4389
+ case '/sandbox/seed':
4390
+ return sandboxAction(session, form, 'seed');
4391
+ case '/sandbox/reset':
4392
+ return sandboxAction(session, form, 'reset');
4393
+ case '/sandbox/fast-forward':
4394
+ return sandboxAction(session, form, 'fast-forward');
2437
4395
  case '/assistant':
2438
4396
  return sendToAssistant(session, form);
4397
+ case '/assistant/stream':
4398
+ return streamToAssistant(session, form, request);
2439
4399
  case '/assistant/approve':
2440
4400
  return settleAction(session, form, 'approve');
2441
4401
  case '/assistant/dismiss':
@@ -2444,10 +4404,20 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2444
4404
  return resetAssistant(session);
2445
4405
  case '/settings/assistant':
2446
4406
  return saveAssistantSettings(session, form);
4407
+ case '/settings/assistant/models':
4408
+ return loadAssistantModels(session, form);
2447
4409
  case '/settings/assistant/test':
2448
4410
  return testAssistant(session);
2449
4411
  case '/settings/assistant/remove':
2450
4412
  return removeAssistantSettings(session);
4413
+ case '/plan/upgrade':
4414
+ return upgradePlan(session, form);
4415
+ case '/plan/manage':
4416
+ return manageBilling(session);
4417
+ case '/teams/enable':
4418
+ return changeTeam(session, form, true);
4419
+ case '/teams/disable':
4420
+ return changeTeam(session, form, false);
2451
4421
  default:
2452
4422
  return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
2453
4423
  }
@@ -2471,11 +4441,24 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2471
4441
  case '/resources':
2472
4442
  case '/webhooks':
2473
4443
  case '/waitlist':
4444
+ case '/approvals':
2474
4445
  case '/assistant':
4446
+ case '/plan':
4447
+ case '/teams':
4448
+ case '/settings/endpoints':
4449
+ case '/settings/hours':
4450
+ case '/settings/account':
2475
4451
  case '/settings': {
2476
4452
  const session = await readSession(request);
2477
4453
  if (!session)
2478
4454
  return redirect(303, root);
4455
+ if (route === '/teams')
4456
+ return await teamsPage(session);
4457
+ if (route === '/plan') {
4458
+ return await planPage(session, 200, url.searchParams.get('checkout') === 'done'
4459
+ ? 'Thank you. Your plan changes here once Stripe confirms the payment, usually within a minute.'
4460
+ : undefined, url.searchParams.get('checkout') === 'done');
4461
+ }
2479
4462
  if (route === '/keys')
2480
4463
  return await keysPage(session);
2481
4464
  if (route === '/resources')
@@ -2484,22 +4467,40 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2484
4467
  return await webhooksPage(session);
2485
4468
  if (route === '/waitlist')
2486
4469
  return await waitlistPage(session);
4470
+ if (route === '/approvals')
4471
+ return await approvalsPage(session);
2487
4472
  if (route === '/assistant')
2488
4473
  return await assistantPage(session);
2489
- return await settingsPage(session);
4474
+ if (route === '/settings/endpoints')
4475
+ return await settingsPage(session, 200, undefined, undefined, 'endpoints');
4476
+ if (route === '/settings/hours')
4477
+ return await settingsPage(session, 200, undefined, undefined, 'hours');
4478
+ if (route === '/settings/account')
4479
+ return await settingsPage(session, 200, undefined, undefined, 'account');
4480
+ // A provider preset fills the assistant's form; nothing is saved until Save.
4481
+ const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
4482
+ return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
2490
4483
  }
2491
4484
  case '/style.css':
2492
- return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
4485
+ return asset(DASHBOARD_ASSETS.css, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
2493
4486
  case '/app.js':
2494
- return asset(DASHBOARD_JS, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
4487
+ return asset(DASHBOARD_ASSETS.js, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
4488
+ case '/favicon.svg':
4489
+ return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
2495
4490
  case '/sign-in':
2496
- return await startSignIn();
4491
+ return google && url.searchParams.get('with') === 'google'
4492
+ ? await startGoogleSignIn(google)
4493
+ : await startSignIn();
2497
4494
  case '/callback':
2498
4495
  return await finishSignIn(request, url);
2499
4496
  case '/hours': {
2500
4497
  const session = await readSession(request);
2501
4498
  return session ? await hoursEditor(session, url) : redirect(303, root);
2502
4499
  }
4500
+ case '/locations': {
4501
+ const session = await readSession(request);
4502
+ return session ? await locationsPage(session) : redirect(303, root);
4503
+ }
2503
4504
  default: {
2504
4505
  const resourceId = RESOURCE_PAGE.exec(route)?.[1];
2505
4506
  if (resourceId !== undefined) {
@@ -2644,6 +4645,12 @@ code, pre, kbd, input[readonly] { font-family: var(--mono); font-size: 12.5px; }
2644
4645
  .workspace select { flex: 1; min-width: 0; height: 26px; border: 0; padding: 0 18px 0 2px; font-weight: 600; font-size: var(--text-md); background-color: transparent; box-shadow: none; text-overflow: ellipsis; }
2645
4646
  .workspace select:focus { box-shadow: none; }
2646
4647
  .workspace:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
4648
+ .environment { display: flex; gap: 2px; margin: calc(-1 * var(--space-2)) 0 0; padding: 2px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
4649
+ .environment button { flex: 1; height: 26px; padding: 0 var(--space-2); border: 0; border-radius: calc(var(--radius) - 2px); background: none; box-shadow: none; color: var(--muted); font-size: var(--text-sm); font-weight: 600; }
4650
+ .environment button[aria-pressed="true"] { background: var(--surface-2); color: var(--ink); box-shadow: 0 0 0 1px var(--line); }
4651
+ .environment button[aria-pressed="true"][value="test"] { background: var(--warn-wash); color: var(--warn); box-shadow: 0 0 0 1px var(--warn-line); }
4652
+ .test-banner { margin: 0 0 var(--space-4); padding: var(--space-2) var(--space-3); border: 1px dashed var(--warn-line); border-radius: var(--radius); background: var(--warn-wash); color: var(--warn); font-size: var(--text-md); }
4653
+ .test-banner code { background: none; padding: 0; }
2647
4654
  .avatar { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: var(--radius-sm); background: var(--ink); color: var(--surface); font-size: 11px; font-weight: 700; }
2648
4655
  .nav { display: grid; gap: 1px; }
2649
4656
  .nav a { position: relative; display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 10px; border-radius: var(--radius); color: var(--ink-2); font-weight: 500; font-size: var(--text-md); transition: background-color var(--fast) var(--ease), color var(--fast) var(--ease); }
@@ -2681,13 +4688,44 @@ a.stat:hover { background: var(--surface-2); text-decoration: none; }
2681
4688
  a.stat::after { content: ""; position: absolute; top: 18px; right: 18px; width: 6px; height: 6px; border-top: 1.5px solid var(--ink-3); border-right: 1.5px solid var(--ink-3); transform: rotate(45deg); opacity: 0; transition: opacity var(--fast) var(--ease); }
2682
4689
  a.stat:hover::after, a.stat:focus-visible::after { opacity: 1; }
2683
4690
  a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
2684
- .stat-label { font: 500 var(--text-xs)/1.4 var(--mono); color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; }
4691
+ .stat-label { font-size: var(--text-sm); line-height: 1.4; color: var(--ink-3); }
4692
+ .attention-stat .stat-value { color: var(--accent); }
2685
4693
  .stat-value { font-size: var(--text-2xl); line-height: 1.1; font-weight: 500; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
2686
4694
 
2687
- /* Setup checklist */
2688
- .setup .steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; counter-reset: step; }
2689
- .steps li { display: flex; align-items: center; gap: var(--space-3); min-height: 36px; color: var(--ink); font-weight: 500; }
2690
- .steps li.done { color: var(--ink-3); font-weight: 400; text-decoration: line-through; text-decoration-color: var(--line-strong); }
4695
+ /* Overview: what needs a person, getting started, the side column */
4696
+ .column { display: grid; gap: var(--space-5); align-content: start; min-width: 0; }
4697
+ .panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); }
4698
+ .panel-head h2 { margin: 0; }
4699
+ .panel-head .meta { flex: none; }
4700
+ .attention-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
4701
+ .attention { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); font-size: var(--text-md); color: var(--ink); box-shadow: inset 3px 0 0 var(--ink-3); }
4702
+ .attention.bad { border-color: var(--bad-line); background: var(--bad-wash); box-shadow: inset 3px 0 0 var(--bad); }
4703
+ .attention.warn { border-color: var(--warn-line); background: var(--warn-wash); box-shadow: inset 3px 0 0 var(--warn); }
4704
+ .attention.info { box-shadow: inset 3px 0 0 var(--accent); }
4705
+ .attention-text { flex: 1; min-width: 0; }
4706
+ .attention-action { flex: none; font-weight: 600; white-space: nowrap; }
4707
+ .segments { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; }
4708
+ .segments span { height: 4px; border-radius: var(--radius-full); background: var(--sunken); }
4709
+ .segments span.done { background: var(--good); }
4710
+ .setup-card .segments { margin-bottom: var(--space-3); }
4711
+ .setup-steps { list-style: none; margin: 0; padding: 0; display: grid; }
4712
+ .setup-steps li { display: flex; align-items: center; gap: var(--space-3); min-height: 44px; border-top: 1px solid var(--line-soft); color: var(--ink-3); }
4713
+ .setup-steps li:first-child { border-top: 0; }
4714
+ .setup-steps li.next { color: var(--ink); font-weight: 500; }
4715
+ .setup-steps li.done .step-title { text-decoration: line-through; text-decoration-color: var(--line-strong); }
4716
+ .setup-steps .step-title { flex: 1; min-width: 0; }
4717
+ .setup-row { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); color: var(--ink-2); font-size: var(--text-md); }
4718
+ .setup-row:hover { background: var(--surface-2); text-decoration: none; }
4719
+ .setup-row .segments { width: 96px; flex: none; }
4720
+ .plan-card .panel-head h2 { font-size: var(--text-lg); }
4721
+ .meters.stacked { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); margin: 0 0 var(--space-4); }
4722
+ .plan-card > .button { width: 100%; justify-content: center; }
4723
+ details.command { margin-top: var(--space-4); }
4724
+ details.command > summary { cursor: pointer; font-size: var(--text-md); color: var(--ink-2); }
4725
+ details.command[open] > summary { margin-bottom: var(--space-2); }
4726
+ .copy-block { position: relative; }
4727
+ .copy-block pre { padding-right: 72px; font-size: var(--text-sm); }
4728
+ .copy-block button { position: absolute; top: 6px; right: 6px; }
2691
4729
  .check { display: inline-grid; place-items: center; width: 20px; height: 20px; flex: none; border-radius: var(--radius-full); border: 1.5px dashed var(--line-strong); color: var(--surface); }
2692
4730
  .done .check { background: var(--good); border: 0; }
2693
4731
  .check .icon { width: 12px; height: 12px; stroke-width: 3; }
@@ -2705,7 +4743,11 @@ a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
2705
4743
  .on { color: var(--ink-3); font-weight: 400; }
2706
4744
  .meta { font-size: var(--text-sm); color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2707
4745
  .feed { position: relative; }
2708
- .event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr); gap: var(--space-3); align-items: start; padding: 7px 0; }
4746
+ .event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; padding: 7px 0; }
4747
+ .event .title { font-weight: 400; color: var(--ink-2); white-space: normal; overflow-wrap: anywhere; }
4748
+ .event .title strong { font-weight: 600; color: var(--ink); }
4749
+ .event .when { font-size: var(--text-sm); font-weight: 400; color: var(--ink-3); white-space: nowrap; padding-top: 1px; }
4750
+ .times { font-size: var(--text-sm); color: var(--ink-3); }
2709
4751
  .event:not(:last-child)::before { content: ""; position: absolute; left: 5.5px; top: 22px; bottom: -8px; width: 1px; background: var(--line); }
2710
4752
  .dot { position: relative; width: 12px; height: 12px; margin-top: 4px; border-radius: var(--radius-full); background: var(--surface); box-shadow: inset 0 0 0 3px var(--ink-3); }
2711
4753
  .dot.good { box-shadow: inset 0 0 0 3px var(--good); }
@@ -2744,9 +4786,11 @@ textarea { padding: 10px var(--space-3); resize: vertical; min-height: 72px; lin
2744
4786
  .create { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin: 0; }
2745
4787
  .create .grow { flex: 1 1 220px; }
2746
4788
  label > input, label > select, label > .unit-input { width: 100%; }
4789
+ label > textarea { width: 100%; padding: var(--space-2) var(--space-3); resize: vertical; }
2747
4790
  label.choice > input { width: 16px; }
2748
4791
  .create label:not(.grow) { flex: 0 1 auto; }
2749
4792
  form > label { margin-bottom: var(--space-3); }
4793
+ .create > label { margin-bottom: 0; }
2750
4794
  .grid-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4) var(--space-3); margin-bottom: var(--space-4); align-items: end; }
2751
4795
  .unit-input { display: flex; align-items: center; gap: var(--space-2); }
2752
4796
  .unit-input input { width: 100%; }
@@ -2761,6 +4805,12 @@ form .choice { margin: var(--space-1) 0 var(--space-3); }
2761
4805
  .types summary { cursor: pointer; color: var(--ink-2); font-size: var(--text-md); width: fit-content; }
2762
4806
  .checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--space-2) var(--space-4); margin-top: var(--space-3); padding: var(--space-4); border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-2); }
2763
4807
  .checks .choice { font-size: var(--text-md); margin: 0; }
4808
+ .choices { border: 0; padding: 0; margin: var(--space-4) 0; min-width: 0; }
4809
+ .choices legend { font-size: var(--text-sm); font-weight: 500; color: var(--ink-2); padding: 0; }
4810
+ .choices .checks { margin-top: 6px; }
4811
+ .fine + .table, .fine + .empty { margin-top: var(--space-4); }
4812
+ .pop label { margin-bottom: var(--space-3); }
4813
+ .pop textarea { width: 100%; }
2764
4814
 
2765
4815
  /* Buttons */
2766
4816
  button, .button { font: inherit; font-size: var(--text-md); font-weight: 500; height: 36px; padding: 0 var(--space-4); border-radius: var(--radius); border: 1px solid var(--line-strong); background: var(--surface); color: var(--ink); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); white-space: nowrap; transition: background-color var(--fast) var(--ease), border-color var(--fast) var(--ease), color var(--fast) var(--ease), transform var(--fast) var(--ease); }
@@ -2826,6 +4876,22 @@ form > .table { margin-bottom: var(--space-3); }
2826
4876
  /* Notices and empty states */
2827
4877
  .notice { display: block; border: 1px solid var(--line); border-radius: var(--radius); padding: var(--space-3) var(--space-4); background: var(--surface); color: var(--ink); margin: 0; font-size: var(--text-md); }
2828
4878
  .notice[role="alert"] { border-color: var(--bad-line); background: var(--bad-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--bad); }
4879
+ .meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-5) var(--space-6); margin: var(--space-4) 0; }
4880
+ .meter { display: grid; gap: 6px; min-width: 0; }
4881
+ .meter-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }
4882
+ .meter-value { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
4883
+ .meter progress { appearance: none; -webkit-appearance: none; width: 100%; height: 6px; border: 0; border-radius: var(--radius-full); background: var(--sunken); overflow: hidden; }
4884
+ .meter progress::-webkit-progress-bar { background: var(--sunken); border-radius: var(--radius-full); }
4885
+ .meter progress::-webkit-progress-value { background: var(--ink-2); border-radius: var(--radius-full); }
4886
+ .meter progress::-moz-progress-bar { background: var(--ink-2); border-radius: var(--radius-full); }
4887
+ .meter.warn progress::-webkit-progress-value { background: var(--warn); }
4888
+ .meter.warn progress::-moz-progress-bar { background: var(--warn); }
4889
+ .meter.bad progress::-webkit-progress-value { background: var(--bad); }
4890
+ .meter.bad progress::-moz-progress-bar { background: var(--bad); }
4891
+ .meter.bad .meter-value { color: var(--bad); }
4892
+ .plan-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
4893
+ .plan-head h2 { margin: 0; }
4894
+ .notice.done { border-color: color-mix(in srgb, var(--good) 35%, var(--line)); background: var(--good-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--good); }
2829
4895
  .notice.warn, .notice.warn[role="alert"] { border-color: var(--warn-line); background: var(--warn-wash); color: var(--warn); box-shadow: inset 3px 0 0 var(--warn); }
2830
4896
  .notice p { color: inherit; margin: 0 0 var(--space-2); }
2831
4897
  .empty { color: var(--ink-3); margin: 0; }
@@ -2862,23 +4928,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
2862
4928
  .bare-main > .notice { max-width: 480px; }
2863
4929
 
2864
4930
  /* Assistant */
2865
- .chat { display: grid; gap: var(--space-5); }
2866
- .thread { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
2867
- .msg { max-width: 78%; padding: 10px 14px; border-radius: 14px; background: var(--surface-2); border: 1px solid var(--line); }
2868
- .msg p { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
2869
- .msg.mine { justify-self: end; background: var(--button); border-color: var(--button); border-bottom-right-radius: 4px; }
2870
- .msg.mine p { color: var(--button-ink); }
2871
- .msg.bot { justify-self: start; border-bottom-left-radius: 4px; }
2872
- .tool-line { font: 400 var(--text-sm)/1.5 var(--mono); color: var(--ink-3); padding-left: var(--space-1); }
2873
- .thread > .notice { justify-self: start; }
2874
- .action-card { justify-self: start; width: min(100%, 540px); padding: var(--space-4) var(--space-5); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--pop-shadow); display: grid; gap: var(--space-3); }
2875
- .action-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
2876
- .action-card .facts { font-size: var(--text-md); gap: 6px var(--space-4); }
2877
- .action-card .facts dt::first-letter { text-transform: uppercase; }
2878
- .action-card pre.wrap { white-space: pre-wrap; margin: 0; padding: var(--space-2); font-size: 12px; }
2879
- .action-summary { margin: 0; color: var(--ink); font-weight: 500; overflow-wrap: anywhere; }
2880
- .action-card .action-buttons { margin-top: var(--space-1); }
2881
- .composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: end; margin: 0; padding-top: var(--space-4); border-top: 1px solid var(--line-soft); }
2882
4931
 
2883
4932
  @media (max-width: 1080px) {
2884
4933
  .split { grid-template-columns: minmax(0, 1fr); }
@@ -2891,6 +4940,7 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
2891
4940
  .side-top .brand .name { display: none; }
2892
4941
  .workspace { flex: 1; min-width: 0; padding: 3px 4px; }
2893
4942
  .workspace .avatar { display: none; }
4943
+ .environment { margin: 0; flex: none; }
2894
4944
  .me { grid-column: 2; grid-row: 1; margin: 0; padding: 0; border: 0; }
2895
4945
  .me-avatar, .me .login { display: none; }
2896
4946
  .sign-out { width: 36px; height: 36px; padding: 0; }
@@ -2907,6 +4957,8 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
2907
4957
  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2908
4958
  .stat { padding: 14px var(--space-4); box-shadow: -1px 0 0 var(--line), 0 -1px 0 var(--line); }
2909
4959
  .stat-value { font-size: 22px; }
4960
+ .attention { flex-wrap: wrap; gap: var(--space-1) var(--space-4); }
4961
+ .setup-row .segments { width: 56px; }
2910
4962
  .create > * { flex: 1 1 100%; }
2911
4963
  .create button { width: 100%; }
2912
4964
  .endpoints { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
@@ -2929,13 +4981,12 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
2929
4981
  .week th[scope="row"] { display: block; padding: var(--space-2) var(--space-4) 0; }
2930
4982
  .actions { text-align: left; width: auto; padding-top: var(--space-2); }
2931
4983
  .act { margin: 0 var(--space-2) 0 0; }
4984
+ .actions form.inline { margin-right: var(--space-2); }
2932
4985
  .act:first-child, form.inline + .act { margin-left: 0; }
2933
4986
  .act summary { border: 1px solid var(--line-strong); height: 32px; }
2934
4987
  .act.danger summary { border-color: var(--bad-line); }
2935
4988
  .pop { left: 0; right: auto; width: min(300px, calc(100vw - 48px)); transform-origin: top left; }
2936
4989
  .table .pop { margin-left: 0; }
2937
- .msg { max-width: 92%; }
2938
- .composer { grid-template-columns: minmax(0, 1fr); }
2939
4990
  .empty-state { padding: var(--space-8) var(--space-4); }
2940
4991
  .bare-main { padding-top: 6vh; }
2941
4992
  .sign-in { padding: var(--space-6); }
@@ -2947,7 +4998,22 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
2947
4998
  .pill, .chip, .nav a[aria-current="page"] { border: 1px solid CanvasText; }
2948
4999
  }
2949
5000
  `;
5001
+ /** A composite the store refused, in a person's words. */
5002
+ const COMPOSITE_PROBLEMS = Object.freeze({
5003
+ parent_not_found: 'That is not one of your resources.',
5004
+ composite_too_deep: 'A part has no parts of its own, and a whole is no part: choose a resource that stands alone.',
5005
+ composite_parts_limit: 'A whole has at most 50 parts.',
5006
+ composite_busy: 'One of them has a booking or hold still to come. A composite changes only while neither does.',
5007
+ });
5008
+ /** What the Usage page calls each plan. */
5009
+ const PLAN_NAMES = Object.freeze({ free: 'Free', developer: 'Developer', team: 'Team' });
5010
+ const DASHBOARD_ASSETS = Object.freeze({
5011
+ css: DASHBOARD_CSS + SLOTLOCK_ASSISTANT_CSS + SLOTLOCK_SETTINGS_CSS,
5012
+ // The chat's script renders a reply with the server's own Markdown renderer, embedded.
5013
+ js: DASHBOARD_JS + slotlockAssistantScript(renderSlotlockMarkdown.toString()),
5014
+ });
2950
5015
  const ASSET_VERSIONS = Object.freeze({
2951
- css: createHash('sha256').update(DASHBOARD_CSS).digest('base64url').slice(0, 12),
2952
- js: createHash('sha256').update(DASHBOARD_JS).digest('base64url').slice(0, 12),
5016
+ css: createHash('sha256').update(DASHBOARD_ASSETS.css).digest('base64url').slice(0, 12),
5017
+ js: createHash('sha256').update(DASHBOARD_ASSETS.js).digest('base64url').slice(0, 12),
5018
+ icon: createHash('sha256').update(SLOTLOCK_DASHBOARD_ICON_SVG).digest('base64url').slice(0, 12),
2953
5019
  });