slotlock 0.6.2 → 0.7.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 (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  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 +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
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,27 @@ 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_PLAN_LIMITS, SLOTLOCK_PLAN_QUOTAS } from './plans.js';
29
+ 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';
30
+ import { slotlockQuotaPeriod } from './quotas.js';
31
+ import { slotlockUsageMonthToDate, summarizeSlotlockUsage } from './usage.js';
26
32
  import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
33
+ import { SlotlockAgentOperationError } from './agent-server.js';
34
+ import { runSlotlockApprovalGrant } from './agent-store-backend.js';
35
+ import { SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
27
36
  import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
28
37
  /** Resources one dashboard tenant may hold. */
29
- export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
38
+ export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 500;
30
39
  const SESSION_TTL_MS = 12 * 3_600_000;
31
40
  const SIGN_IN_TTL_MS = 10 * 60_000;
41
+ /**
42
+ * How recent a sign-in must be to turn an organization on or off: who owns it is read from GitHub
43
+ * as one signs in, so a change of owners reaches the dashboard by signing in again.
44
+ */
45
+ const FRESH_SIGN_IN_MS = 15 * 60_000;
46
+ /** How long Google's signing keys are kept when its answer does not say (Cache-Control max-age). */
47
+ const GOOGLE_KEYS_DEFAULT_TTL_MS = 3_600_000;
32
48
  const MAX_FORM_BYTES = 8_192;
33
49
  /** A message to the assistant: 2,000 characters, each up to 9 bytes as a form encodes it. */
34
50
  const MAX_ASSISTANT_FORM_BYTES = 24_576;
@@ -48,27 +64,47 @@ const POST_ROUTES = new Set([
48
64
  '/keys',
49
65
  '/keys/rotate',
50
66
  '/keys/revoke',
67
+ '/keys/limits',
51
68
  '/resources',
52
69
  '/resources/rules',
53
70
  '/resources/pool',
71
+ '/resources/profile',
72
+ '/resources/place',
73
+ '/resources/parent',
74
+ '/locations',
75
+ '/locations/delete',
76
+ '/groups',
77
+ '/groups/delete',
54
78
  '/webhooks',
55
79
  '/webhooks/delete',
56
80
  '/webhooks/enable',
57
81
  '/waitlist/leave',
82
+ '/approvals/decide',
83
+ '/sandbox/seed',
84
+ '/sandbox/reset',
85
+ '/sandbox/fast-forward',
58
86
  '/hours',
59
87
  '/organization',
88
+ '/environment',
89
+ '/teams/enable',
90
+ '/teams/disable',
60
91
  '/assistant',
61
92
  '/assistant/approve',
62
93
  '/assistant/dismiss',
63
94
  '/assistant/reset',
64
95
  '/settings/assistant',
65
96
  '/settings/assistant/test',
97
+ '/settings/assistant/models',
66
98
  '/settings/assistant/remove',
99
+ '/plan/upgrade',
100
+ '/plan/manage',
67
101
  ]);
68
102
  const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
69
103
  const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
70
104
  const MAX_COOKIE_CHARACTERS = 4_096;
71
105
  const GITHUB_TIMEOUT_MS = 10_000;
106
+ /** A Google account's subject: ASCII, at most 255 characters (OpenID Connect); Google's are digits. */
107
+ const GOOGLE_SUBJECT = /^[A-Za-z0-9._-]{1,255}$/;
72
108
  /** The REST API version GitHub documents for GET /user (docs.github.com, read 2026-10-10). */
73
109
  const GITHUB_API_VERSION = '2026-03-10';
74
110
  const USER_AGENT = 'slotlock-dashboard';
@@ -97,15 +133,84 @@ const KEY_EXPIRY_DAYS = Object.freeze({
97
133
  */
98
134
  const REFERRER_POLICY = 'same-origin';
99
135
  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'";
136
+ /** The policy, with billing's origins allowed as form targets so Upgrade and Manage can redirect. */
137
+ function policyFor(billing) {
138
+ if (!billing)
139
+ return CONTENT_SECURITY_POLICY;
140
+ const contact = new URL(billing.contact);
141
+ if (contact.protocol !== 'https:' && contact.protocol !== 'mailto:') {
142
+ throw new Error('billing.contact must be an https or mailto URL');
143
+ }
144
+ const origins = billing.formOrigins.map((value) => {
145
+ const parsed = new URL(value);
146
+ if (parsed.protocol !== 'https:' || parsed.origin !== value.replace(/\/$/, '')) {
147
+ throw new Error(`billing.formOrigins must be https origins: ${value}`);
148
+ }
149
+ return parsed.origin;
150
+ });
151
+ return CONTENT_SECURITY_POLICY.replace("form-action 'self'", ["form-action 'self'", ...origins].join(' '));
152
+ }
100
153
  /**
101
154
  * The dashboard's resources, in the store under each tenant's row-level security. Adds hold the cap
102
155
  * in the database (a READ COMMITTED transaction, under a per-tenant lock).
103
156
  */
104
- export function createSlotlockDashboardResources(store) {
157
+ export function createSlotlockDashboardResources(store, options = {}) {
105
158
  // Every change here is the dashboard's, made by the person signed in (actor) when known.
106
159
  const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
160
+ const agentBackend = options.agentBackend;
107
161
  return {
162
+ sandbox: {
163
+ seed: (tenantRef, scenario) => seedSlotlockSandbox(store, tenantRef, scenario),
164
+ async reset(tenantRef) {
165
+ await resetSlotlockSandbox(store, tenantRef);
166
+ },
167
+ async fastForward(tenantRef, minutes) {
168
+ const forward = await fastForwardSlotlockSandbox(store, tenantRef, minutes);
169
+ return { holds: forward.holds, approvals: forward.approvals };
170
+ },
171
+ },
172
+ ...(agentBackend
173
+ ? {
174
+ approvals: {
175
+ list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listApprovals({ tenantRef, limit })),
176
+ async decide(tenantRef, id, decision, actor, reason) {
177
+ const decidedBy = actor ?? 'dashboard';
178
+ if (decision === 'decline') {
179
+ const declined = await store.withTenant(tenantRef, (tenant) => tenant.declineApproval({ tenantRef, id, decidedBy, ...(reason ? { reason } : {}) }), by(actor));
180
+ if (!declined.ok)
181
+ return declined;
182
+ // Its held time is free: offer it to the waitlist, as a release does.
183
+ await store
184
+ .withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), { origin: 'maintenance' })
185
+ .catch(() => undefined);
186
+ return { ok: true };
187
+ }
188
+ const approval = await store.withTenant(tenantRef, (tenant) => tenant.getApproval({ tenantRef, id }));
189
+ if (!approval)
190
+ return { ok: false, code: 'approval_not_found' };
191
+ if (approval.status !== 'pending')
192
+ return { ok: false, code: 'approval_not_pending' };
193
+ try {
194
+ await runSlotlockApprovalGrant(agentBackend, approval, {
195
+ tenantRef,
196
+ decidedBy,
197
+ signal: new AbortController().signal,
198
+ origin: 'dashboard',
199
+ });
200
+ }
201
+ catch (error) {
202
+ if (error instanceof SlotlockAgentOperationError)
203
+ return { ok: false, code: error.code };
204
+ throw error;
205
+ }
206
+ return { ok: true };
207
+ },
208
+ },
209
+ }
210
+ : {}),
108
211
  list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
212
+ count: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.countResources({ tenantRef })),
213
+ usage: (tenantRef, from, to) => store.withTenant(tenantRef, (tenant) => tenant.readUsage({ tenantRef, from, to })),
109
214
  add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
110
215
  tenantRef,
111
216
  externalRef,
@@ -123,6 +228,39 @@ export function createSlotlockDashboardResources(store) {
123
228
  setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
124
229
  ruleDefaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getBookingRuleDefaults({ tenantRef })),
125
230
  setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
231
+ setParent: (tenantRef, id, parentId, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceParent({ tenantRef, id, parentId }), by(actor)),
232
+ setProfile: (tenantRef, id, profile, actor) => store.withTenant(tenantRef, (tenant) => tenant.updateResourceProfile({ tenantRef, id, profile }), by(actor)),
233
+ grouping: {
234
+ locations: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listLocations({ tenantRef })),
235
+ putLocation: (tenantRef, location, actor) => store.withTenant(tenantRef, (tenant) => tenant.putLocation({ tenantRef, ...location }), by(actor)),
236
+ deleteLocation: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteLocation({ tenantRef, ref }), by(actor)),
237
+ groups: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listGroups({ tenantRef })),
238
+ putGroup: (tenantRef, group, actor) => store.withTenant(tenantRef, (tenant) => tenant.putGroup({ tenantRef, ...group }), by(actor)),
239
+ deleteGroup: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteGroup({ tenantRef, ref }), by(actor)),
240
+ place: (tenantRef, id, place, actor) => store.withTenant(tenantRef, async (tenant) => {
241
+ // The location first, which finds the resource (null when it is not the tenant's);
242
+ // then its groups, in the same transaction, so a refused group moves nothing.
243
+ const moved = await tenant.updateResourceProfile({
244
+ tenantRef,
245
+ id,
246
+ profile: {},
247
+ location: place.location,
248
+ });
249
+ if (!moved)
250
+ return null;
251
+ const wanted = new Set(place.groups);
252
+ const current = new Set(moved.groups ?? []);
253
+ for (const group of current) {
254
+ if (!wanted.has(group))
255
+ await tenant.removeGroupMember({ tenantRef, group, resourceId: id });
256
+ }
257
+ for (const group of wanted) {
258
+ if (!current.has(group))
259
+ await tenant.addGroupMember({ tenantRef, group, resourceId: id });
260
+ }
261
+ return tenant.getResource({ tenantRef, id });
262
+ }, by(actor)),
263
+ },
126
264
  waitlist: {
127
265
  list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
128
266
  async leave(tenantRef, id, actor) {
@@ -308,6 +446,8 @@ const AGENDA_LIMIT = 200;
308
446
  const AGENDA_SHOWN = 30;
309
447
  const ACTIVITY_LIMIT = 20;
310
448
  const WAITLIST_SHOWN = 200;
449
+ /** Approvals the dashboard lists, pending and recent decided together, newest first. */
450
+ const APPROVALS_SHOWN = 100;
311
451
  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
452
  /** How the booking rules form names each rule. */
313
453
  const RULE_LIMITS = SLOTLOCK_BOOKING_RULE_LIMITS;
@@ -378,12 +518,122 @@ const ICONS = Object.freeze({
378
518
  waitlist: icon('<path d="M4 6h16M4 12h10M4 18h6"/><circle cx="18" cy="16" r="3"/>'),
379
519
  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
520
  assistant: icon('<path d="M4 5h16v11H9l-5 4z"/><path d="M8 9h8M8 12h5"/>'),
521
+ plan: icon('<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M7 15h4"/>'),
522
+ 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
523
  settings: icon('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
382
524
  check: icon('<path d="M5 12l5 5L20 7"/>'),
525
+ approvals: icon('<circle cx="12" cy="12" r="8"/><path d="M8.5 12.5l2.5 2.5 4.5-5"/>'),
383
526
  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
527
  plus: icon('<path d="M12 5v14M5 12h14"/>'),
528
+ 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
529
  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
530
  });
531
+ /** How the dashboard names each kind of resource. */
532
+ const KIND_NAMES = Object.freeze({
533
+ person: 'Person',
534
+ room: 'Room',
535
+ desk: 'Desk',
536
+ space: 'Space',
537
+ vehicle: 'Vehicle',
538
+ machine: 'Machine',
539
+ equipment: 'Equipment',
540
+ service: 'Service',
541
+ other: 'Not said',
542
+ });
543
+ /** What a profile field it could not keep must be, in a person's words. */
544
+ const PROFILE_PROBLEMS = Object.freeze({
545
+ name: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.`,
546
+ description: `A description is at most ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description.toLocaleString('en')} characters.`,
547
+ kind: 'Choose a kind from the list.',
548
+ capacity: `Capacity is a whole number from 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.capacity.toLocaleString('en')}. Leave it empty for none.`,
549
+ labels: `Up to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.labels} labels, each 1 to 64 letters, digits, dots, dashes or underscores.`,
550
+ attributes: 'Those attributes are not ones Slotlock can keep.',
551
+ other: 'That profile is not one Slotlock can keep.',
552
+ });
553
+ const GROUPING_REF_RULE = 'A reference is 1 to 100 lowercase letters, digits, dots, dashes or underscores, starting with a letter or digit.';
554
+ /** What a person is told when a location or group change is refused, by the store's code. */
555
+ const GROUPING_PROBLEMS = Object.freeze({
556
+ location_ref: { status: 400, text: GROUPING_REF_RULE },
557
+ location_name: { status: 400, text: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.` },
558
+ location_parent: { status: 400, text: 'Choose where it is from the list, or nowhere.' },
559
+ location_timezone: {
560
+ status: 400,
561
+ text: 'A time zone is a name such as Europe/London. Leave it empty to take the one of the location it is in.',
562
+ },
563
+ location_other: { status: 400, text: 'That location is not one Slotlock can keep.' },
564
+ location_not_found: { status: 404, text: 'That location is not one of yours.' },
565
+ location_too_deep: { status: 409, text: `Locations go ${SLOTLOCK_MAX_LOCATION_DEPTH} levels deep at most.` },
566
+ location_cycle: { status: 409, text: 'A location cannot be inside itself, or inside a location that is inside it.' },
567
+ location_limit_reached: {
568
+ status: 409,
569
+ text: `You have ${SLOTLOCK_MAX_LOCATIONS.toLocaleString('en')} locations, the most there can be.`,
570
+ },
571
+ location_in_use: {
572
+ status: 409,
573
+ text: 'Move its resources, and the locations inside it, somewhere else first.',
574
+ },
575
+ group_ref: { status: 400, text: GROUPING_REF_RULE },
576
+ group_name: { status: 400, text: `A name is 1 to ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} characters on one line.` },
577
+ group_description: {
578
+ status: 400,
579
+ text: `A description is at most ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description.toLocaleString('en')} characters.`,
580
+ },
581
+ group_other: { status: 400, text: 'That group is not one Slotlock can keep.' },
582
+ group_not_found: { status: 404, text: 'That group is not one of yours.' },
583
+ group_limit_reached: {
584
+ status: 409,
585
+ text: `You have ${SLOTLOCK_MAX_RESOURCE_GROUPS.toLocaleString('en')} groups, the most there can be.`,
586
+ },
587
+ resource_group_limit_reached: {
588
+ status: 409,
589
+ text: `A resource is in ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE} groups at most.`,
590
+ },
591
+ });
592
+ /** The sentence for a refused location or group change, or null when it is not one. */
593
+ function groupingProblem(error) {
594
+ const code = error?.code;
595
+ if (code === 'invalid_location' || code === 'invalid_group') {
596
+ const kind = code === 'invalid_location' ? 'location' : 'group';
597
+ const field = error.field;
598
+ return GROUPING_PROBLEMS[`${kind}_${typeof field === 'string' ? field : 'other'}`] ?? GROUPING_PROBLEMS[`${kind}_other`] ?? null;
599
+ }
600
+ return typeof code === 'string' ? (GROUPING_PROBLEMS[code] ?? null) : null;
601
+ }
602
+ /** Each location's place from the top, as "London › Floor 3", by reference. */
603
+ function locationPaths(locations) {
604
+ const byRef = new Map(locations.map((location) => [location.ref, location]));
605
+ const paths = new Map();
606
+ for (const location of locations) {
607
+ const names = [];
608
+ let at = location;
609
+ for (let steps = 0; at && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
610
+ names.unshift(at.name);
611
+ at = at.parentRef === null ? undefined : byRef.get(at.parentRef);
612
+ }
613
+ paths.set(location.ref, names.join(' › '));
614
+ }
615
+ return paths;
616
+ }
617
+ /** Whether `ref` is `ancestor`, or a location inside it. */
618
+ function locationWithin(locations, ref, ancestor) {
619
+ let at = ref;
620
+ for (let steps = 0; at !== null && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
621
+ if (at === ancestor)
622
+ return true;
623
+ at = locations.get(at)?.parentRef ?? null;
624
+ }
625
+ return false;
626
+ }
627
+ /** The time zone a location gives a resource: its own, or the nearest one above it, and where from. */
628
+ function locationZone(locations, location) {
629
+ let at = location;
630
+ for (let steps = 0; at && steps <= SLOTLOCK_MAX_LOCATION_DEPTH; steps += 1) {
631
+ if (at.timezone !== null)
632
+ return { timezone: at.timezone, from: at };
633
+ at = at.parentRef === null ? undefined : locations.get(at.parentRef);
634
+ }
635
+ return null;
636
+ }
387
637
  /** How the overview names each change the feed records. */
388
638
  const CHANGE_LABELS = Object.freeze({
389
639
  'hold.created': { text: 'Hold placed', tone: 'neutral' },
@@ -404,7 +654,12 @@ const CHANGE_LABELS = Object.freeze({
404
654
  'coverage.stale': { text: 'Calendar sync is behind', tone: 'bad' },
405
655
  'waitlist.offered': { text: 'Waitlist offer sent', tone: 'good' },
406
656
  'waitlist.expired': { text: 'Waitlist offer lapsed', tone: 'neutral' },
657
+ 'approval.requested': { text: 'Waiting for approval', tone: 'neutral' },
658
+ 'approval.granted': { text: 'Approved and booked', tone: 'good' },
659
+ 'approval.declined': { text: 'Approval declined', tone: 'bad' },
660
+ 'approval.expired': { text: 'Approval not decided in time', tone: 'bad' },
407
661
  'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
662
+ 'usage.threshold_reached': { text: 'Monthly quota nearly or fully used', tone: 'bad' },
408
663
  });
409
664
  const zoned = (zone, parts) => {
410
665
  try {
@@ -645,6 +900,11 @@ export function createSlotlockDashboard(options) {
645
900
  const base = publicUrl.pathname.replace(/\/$/, '');
646
901
  const root = `${base}/dashboard`;
647
902
  const callbackUrl = `${origin}${root}/callback`;
903
+ const publicRoot = `${origin}${root}`;
904
+ const contentSecurityPolicy = policyFor(options.billing);
905
+ // Its cookies are Secure, but a first visit over http:// could still be answered by someone else:
906
+ // a browser that has seen this goes straight to https:// for a year. Not for a local http one.
907
+ const hsts = publicUrl.protocol === 'https:' ? { 'Strict-Transport-Security': 'max-age=31536000' } : {};
648
908
  const service = new URL(options.serviceUrl ?? options.publicUrl);
649
909
  const serviceRoot = `${service.origin}${service.pathname.replace(/\/$/, '')}`;
650
910
  const mcpUrl = `${serviceRoot}/mcp`;
@@ -654,14 +914,31 @@ export function createSlotlockDashboard(options) {
654
914
  const userUrl = options.github.userUrl ?? 'https://api.github.com/user';
655
915
  const membershipsUrl = options.github.membershipsUrl ??
656
916
  'https://api.github.com/user/memberships/orgs?state=active&per_page=100';
657
- const served = options.organizations ? new Set(options.organizations) : null;
917
+ const served = Array.isArray(options.organizations) ? new Set(options.organizations) : null;
658
918
  if (served && (served.size === 0 || [...served].some((id) => !ORGANIZATION_ID.test(id)))) {
659
919
  throw new Error('Slotlock dashboard organizations must be GitHub organization ids');
660
920
  }
921
+ // Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
922
+ const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
923
+ if (options.organizations === 'enabled' && !teams) {
924
+ throw new Error('Slotlock dashboard organizations "enabled" needs the teams store');
925
+ }
926
+ /** Sign-in reads the person's organizations: a fixed list, or those turned on. */
927
+ const readsOrganizations = served !== null || teams !== null;
928
+ /** Each session's organizations turned on, read with it (teams mode). */
929
+ const enabledOf = new WeakMap();
661
930
  const brandName = options.name ?? 'Slotlock';
662
931
  if (!SLOTLOCK_DASHBOARD_NAME_PATTERN.test(brandName) || brandName.trim() !== brandName) {
663
932
  throw new Error('Slotlock dashboard name must be 1-60 characters, without <, > or control or invisible characters');
664
933
  }
934
+ for (const [label, url] of [
935
+ ['termsUrl', options.termsUrl],
936
+ ['privacyUrl', options.privacyUrl],
937
+ ]) {
938
+ if (url !== undefined && !/^https:\/\/[^\s"'<>`]+$/.test(url)) {
939
+ throw new Error(`Slotlock dashboard ${label} must be an https URL`);
940
+ }
941
+ }
665
942
  const fetchImpl = options.fetch ?? fetch;
666
943
  const now = options.now ?? Date.now;
667
944
  if (Buffer.byteLength(options.sessionSecret, 'utf8') < 32) {
@@ -669,8 +946,10 @@ export function createSlotlockDashboard(options) {
669
946
  }
670
947
  const secret = Buffer.from(options.sessionSecret, 'utf8');
671
948
  // A list, or exactly '*': any other string would match ids by substring.
672
- const allowed = (uid) => options.allowedUsers === '*' ||
673
- (Array.isArray(options.allowedUsers) && options.allowedUsers.includes(uid));
949
+ // A GitHub person by their bare id, a Google one as google:<subject>.
950
+ const allowed = (uid, idp) => options.allowedUsers === '*' ||
951
+ (Array.isArray(options.allowedUsers) && options.allowedUsers.includes(idp === 'google' ? `google:${uid}` : uid));
952
+ const google = served ? null : (options.google ?? null);
674
953
  let signInsInFlight = 0;
675
954
  /** Sign-ins in flight by client address. */
676
955
  const signInsByClient = new Map();
@@ -738,18 +1017,39 @@ export function createSlotlockDashboard(options) {
738
1017
  typeof payload.uid !== 'string' ||
739
1018
  typeof payload.login !== 'string' ||
740
1019
  typeof payload.sid !== 'string' ||
741
- !allowed(payload.uid) ||
1020
+ (payload.idp !== undefined && (payload.idp !== 'google' || !google)) ||
1021
+ !allowed(payload.uid, payload.idp) ||
742
1022
  !organizationsHold(payload) ||
1023
+ (payload.env !== undefined && payload.env !== 'test') ||
743
1024
  (await options.state.sessionEnded(payload.sid, payload.exp))) {
744
1025
  return null;
745
1026
  }
746
- return payload;
1027
+ const session = payload;
1028
+ if (teams) {
1029
+ // Which of the person's organizations are on now: one turned off since is left at once.
1030
+ const enabled = session.orgs?.length ? await teams.enabled(session.orgs.map((org) => org.id)) : new Set();
1031
+ if (session.org !== undefined && !enabled.has(session.org)) {
1032
+ const { org: _, ...personal } = session;
1033
+ enabledOf.set(personal, enabled);
1034
+ return personal;
1035
+ }
1036
+ enabledOf.set(session, enabled);
1037
+ }
1038
+ return session;
747
1039
  }
748
1040
  /**
749
1041
  * With organizations, the session names one the server still serves, from its own list; without
750
1042
  * them, it names none. A session from the other mode is signed out.
751
1043
  */
752
1044
  function organizationsHold(payload) {
1045
+ if (teams) {
1046
+ if (payload.orgs === undefined)
1047
+ return payload.org === undefined;
1048
+ if (!Array.isArray(payload.orgs))
1049
+ return false;
1050
+ const listed = payload.orgs.filter((org) => isRecord(org) && typeof org.id === 'string' && typeof org.login === 'string');
1051
+ return payload.org === undefined || (typeof payload.org === 'string' && listed.some((org) => org.id === payload.org));
1052
+ }
753
1053
  if (!served)
754
1054
  return payload.org === undefined && payload.orgs === undefined;
755
1055
  if (typeof payload.org !== 'string' || !served.has(payload.org) || !Array.isArray(payload.orgs)) {
@@ -760,20 +1060,26 @@ export function createSlotlockDashboard(options) {
760
1060
  const csrfToken = (session) => createHmac('sha256', secret)
761
1061
  .update(`slotlock-dashboard-csrf-v1.${session.sid}`)
762
1062
  .digest('base64url');
763
- const tenantOf = (session) => served ? `github-org:${session.org}` : `github:${session.uid}`;
1063
+ /** The tenant the session acts in: the workspace's, or its test environment's (`~test`). */
1064
+ const tenantOf = (session) => `${session.org !== undefined ? `github-org:${session.org}` : personOf(session)}${session.env === 'test' ? SLOTLOCK_TEST_TENANT_SUFFIX : ''}`;
764
1065
  /** Who did something, for the record: the person, whatever tenant they act in. */
765
- const personOf = (session) => `github:${session.uid}`;
1066
+ function personOf(session) {
1067
+ return session.idp === 'google' ? `google:${session.uid}` : `github:${session.uid}`;
1068
+ }
1069
+ /** How the dashboard shows the person: their GitHub login, or their Google account's email. */
1070
+ const shownAs = (session) => (session.idp === 'google' ? session.login : `@${session.login}`);
766
1071
  function respond(status, body, headers = {}, cookies = []) {
767
1072
  const response = new Response(String(body), {
768
1073
  status,
769
1074
  headers: {
770
1075
  'Content-Type': 'text/html; charset=utf-8',
771
- 'Content-Security-Policy': CONTENT_SECURITY_POLICY,
1076
+ 'Content-Security-Policy': contentSecurityPolicy,
772
1077
  'Cache-Control': 'no-store',
773
1078
  'X-Frame-Options': 'DENY',
774
1079
  'X-Content-Type-Options': 'nosniff',
775
1080
  'Referrer-Policy': REFERRER_POLICY,
776
1081
  'Cross-Origin-Opener-Policy': 'same-origin',
1082
+ ...hsts,
777
1083
  ...headers,
778
1084
  },
779
1085
  });
@@ -790,6 +1096,9 @@ export function createSlotlockDashboard(options) {
790
1096
  ...(options.resources.waitlist
791
1097
  ? [{ section: 'waitlist', label: 'Waitlist', path: '/waitlist', icon: ICONS.waitlist }]
792
1098
  : []),
1099
+ ...(options.resources.approvals
1100
+ ? [{ section: 'approvals', label: 'Approvals', path: '/approvals', icon: ICONS.approvals }]
1101
+ : []),
793
1102
  { section: 'keys', label: 'API keys', path: '/keys', icon: ICONS.keys },
794
1103
  ...(options.resources.webhooks
795
1104
  ? [{ section: 'webhooks', label: 'Webhooks', path: '/webhooks', icon: ICONS.webhooks }]
@@ -797,14 +1106,27 @@ export function createSlotlockDashboard(options) {
797
1106
  ...(assistantStore()
798
1107
  ? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
799
1108
  : []),
1109
+ ...(options.billing
1110
+ ? [{ section: 'plan', label: 'Plan', path: '/plan', icon: ICONS.plan }]
1111
+ : []),
1112
+ ...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
800
1113
  { section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
801
1114
  ];
1115
+ /** Live or Test (RFC 0003 G9): the same workspace, a tenant of its own for trying things. */
1116
+ function environmentSwitch(session) {
1117
+ 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>`;
1118
+ 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>`;
1119
+ }
802
1120
  /** The workspace the session acts in: its organization, or the person themselves. */
803
1121
  function workspace(session) {
804
1122
  const current = session.orgs?.find((org) => org.id === session.org);
805
1123
  const choices = (session.orgs ?? []).filter((org) => served?.has(org.id));
806
1124
  const name = current?.login ?? session.login;
807
1125
  const badge = html `<span class="avatar" aria-hidden="true">${name.slice(0, 1).toUpperCase()}</span>`;
1126
+ const enabled = teams ? (session.orgs ?? []).filter((org) => enabledOf.get(session)?.has(org.id)) : [];
1127
+ if (teams && enabled.length > 0) {
1128
+ 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>`;
1129
+ }
808
1130
  if (current && choices.length > 1) {
809
1131
  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>`;
810
1132
  }
@@ -819,6 +1141,7 @@ export function createSlotlockDashboard(options) {
819
1141
  <meta name="viewport" content="width=device-width, initial-scale=1">
820
1142
  <meta name="referrer" content="${REFERRER_POLICY}">
821
1143
  <title>${title} · ${brandName}</title>
1144
+ <link rel="icon" href="${root}/favicon.svg?v=${ASSET_VERSIONS.icon}" type="image/svg+xml">
822
1145
  <link rel="stylesheet" href="${root}/style.css?v=${ASSET_VERSIONS.css}">
823
1146
  <script src="${root}/app.js?v=${ASSET_VERSIONS.js}" defer></script>
824
1147
  </head>`;
@@ -842,11 +1165,13 @@ ${live}
842
1165
  ${skip}
843
1166
  <div class="app">
844
1167
  <header class="side">
845
- <div class="side-top">${brand}${workspace(session)}</div>
1168
+ <div class="side-top">${brand}${workspace(session)}${environmentSwitch(session)}</div>
846
1169
  <nav class="nav" aria-label="Dashboard">${links}</nav>
847
- <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>
1170
+ <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>
848
1171
  </header>
849
- <main class="page" id="main">${content}</main>
1172
+ <main class="page" id="main">${session.env === 'test'
1173
+ ? 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>`
1174
+ : html ``}${content}</main>
850
1175
  </div>
851
1176
  ${live}
852
1177
  </body>
@@ -859,27 +1184,52 @@ ${live}
859
1184
  <h1>Sign in to ${brandName}</h1>
860
1185
  <p>Create keys for your agents, add what they book, and watch what they do.</p>
861
1186
  ${noticeOf(notice)}
862
- <a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a>
863
- <p class="fine">${served
864
- ? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and the organizations it serves.`
865
- : `${brandName} reads only your public GitHub profile, and keeps only your user id and login.`}</p>
1187
+ <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 ``}
1188
+ ${options.termsUrl
1189
+ ? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
1190
+ ? html ` The <a href="${options.privacyUrl}">Privacy notice</a> says what is kept.`
1191
+ : html ``}</p>`
1192
+ : html ``}
1193
+ <p class="fine">${teams
1194
+ ? `${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.`
1195
+ : served
1196
+ ? `${brandName} reads your GitHub profile and which organizations you belong to, and keeps only your user id, login and the organizations it serves.`
1197
+ : `${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>
866
1198
  </section>`;
867
1199
  return respond(status, layout('Sign in', content, null), {}, cookies);
868
1200
  }
1201
+ /** A key's own limits (RFC 0004 A5) as fields, filled with what it has. */
1202
+ 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>
1203
+ <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>
1204
+ <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>
1205
+ <label class="choice"><input type="checkbox" name="requires_approval" value="on"${limits?.requiresApproval ? html ` checked` : html ``}> Its bookings wait for approval</label>`;
1206
+ /** A key's limits in words, for its row. */
1207
+ const keyLimitsText = (limits) => [
1208
+ limits.maxHolds !== undefined ? `${String(limits.maxHolds)} live holds` : '',
1209
+ limits.maxHeldHours !== undefined ? `${String(limits.maxHeldHours)} hours held` : '',
1210
+ limits.requestsPerMinute !== undefined ? `${String(limits.requestsPerMinute)} requests a minute` : '',
1211
+ limits.requiresApproval ? 'bookings need approval' : '',
1212
+ ]
1213
+ .filter(Boolean)
1214
+ .join(', ');
869
1215
  function keyRows(keys, token, session) {
870
1216
  return keys.map((key) => {
871
1217
  const expired = key.expiresAt !== null && key.expiresAt.getTime() <= now();
872
1218
  const status = key.revokedAt ? 'Revoked' : expired ? 'Expired' : 'Active';
873
1219
  const actions = status === 'Active'
874
- ? 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>
1220
+ ? 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>
1221
+ <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>
1222
+ <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>
875
1223
  <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>
876
1224
  <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>
877
1225
  <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>`
878
1226
  : html ``;
879
1227
  return html `<tr>
880
- <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>
1228
+ <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>
881
1229
  <td data-label="Key"><code class="chip">${key.prefix}…</code></td>
882
- <td data-label="Access">${key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only'}</td>
1230
+ <td data-label="Access">${key.resourceScope || key.limits
1231
+ ? 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>`
1232
+ : accessOf(key)}</td>
883
1233
  <td data-label="Created">${dateCell(key.createdAt)}</td>
884
1234
  <td data-label="Last used">${key.lastUsedAt ? dateCell(key.lastUsedAt) : html `<span class="muted">Never</span>`}</td>
885
1235
  <td data-label="Expires">${key.expiresAt ? dateCell(key.expiresAt) : html `<span class="muted">Never</span>`}</td>
@@ -892,7 +1242,8 @@ ${noticeOf(notice)}
892
1242
  /** A list with nothing in it yet: what goes here, and the one thing to do next. */
893
1243
  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>`;
894
1244
  /** A resource as people name it: its reference, else the start of its id. */
895
- const resourceName = (resource, id) => resource?.externalRef ?? `${id.slice(0, 8)}…`;
1245
+ // Its profile's name, else its reference, else the start of its id.
1246
+ const resourceName = (resource, id) => resource?.name ?? resource?.externalRef ?? `${id.slice(0, 8)}…`;
896
1247
  function agendaPanel(entries, byId, more) {
897
1248
  if (entries.length === 0) {
898
1249
  return html `<p class="empty">Nothing booked in the next ${String(AGENDA_DAYS)} days.</p>`;
@@ -938,11 +1289,15 @@ ${noticeOf(notice)}
938
1289
  ? 'A deleted key'
939
1290
  : /^github:\d+$/.test(change.actor)
940
1291
  ? `GitHub user ${change.actor.slice(7)}`
941
- : /^dashboard-assistant:github:\d+$/.test(change.actor)
942
- ? change.actor === assistantActor(session)
943
- ? `Assistant for @${session.login}`
944
- : `Assistant for GitHub user ${change.actor.slice(27)}`
945
- : change.actor.slice(0, 60)));
1292
+ : /^google:/.test(change.actor)
1293
+ ? 'A Google user'
1294
+ : /^dashboard-assistant:(github|google):/.test(change.actor)
1295
+ ? change.actor === assistantActor(session)
1296
+ ? `Assistant for ${shownAs(session)}`
1297
+ : change.actor.startsWith('dashboard-assistant:google:')
1298
+ ? 'Assistant for a Google user'
1299
+ : `Assistant for GitHub user ${change.actor.slice(27)}`
1300
+ : change.actor.slice(0, 60)));
946
1301
  const where = change.resourceId
947
1302
  ? resourceName(byId.get(change.resourceId), change.resourceId)
948
1303
  : null;
@@ -981,18 +1336,74 @@ ${agenda ? stat('Live holds', counted(held), `${root}#upcoming`) : html ``}
981
1336
  ${stat('Resources', String(resources.length), `${root}/resources`)}
982
1337
  ${stat('Active keys', String(active.length), `${root}/keys`)}
983
1338
  </div>
1339
+ ${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
984
1340
  <div class="split">
985
1341
  ${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
986
1342
  ${changes ? html `<section class="panel"><h2>Agent activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
987
1343
  </div>`;
988
1344
  return respond(status, layout('Overview', content, session, 'overview'));
989
1345
  }
1346
+ /** In Test: load a scenario to try an agent against, move waiting forward, or start again (RFC 0004 A6). */
1347
+ function sandboxPanel(session, resourceCount) {
1348
+ const token = csrfToken(session);
1349
+ const hidden = html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
1350
+ const names = { 'car-rental': 'Car rental', clinic: 'Clinic', desks: 'Desks and a hall' };
1351
+ return html `<section class="panel" id="sandbox"><h2>Sandbox</h2>
1352
+ <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>
1353
+ ${resourceCount === 0
1354
+ ? 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>`
1355
+ : 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>
1356
+ <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>`}
1357
+ </section>`;
1358
+ }
1359
+ async function sandboxAction(session, form, action) {
1360
+ const sandbox = options.resources.sandbox;
1361
+ if (!sandbox || session.env !== 'test')
1362
+ return overviewPage(session, 400, 'The sandbox is in Test only.');
1363
+ const tenantRef = tenantOf(session);
1364
+ try {
1365
+ if (action === 'seed') {
1366
+ const scenario = form.get('scenario');
1367
+ if (!SLOTLOCK_SANDBOX_SCENARIOS.includes(scenario))
1368
+ return overviewPage(session, 400, 'Choose a sample from the list.');
1369
+ const seeded = await sandbox.seed(tenantRef, scenario);
1370
+ return overviewPage(session, 200, `Loaded ${String(seeded.resources)} resources and ${String(seeded.bookings)} bookings for tomorrow.`);
1371
+ }
1372
+ if (action === 'reset') {
1373
+ await sandbox.reset(tenantRef);
1374
+ return redirect(303, `${root}#sandbox`);
1375
+ }
1376
+ const minutes = Number(form.get('minutes'));
1377
+ if (![15, 60, 1440].includes(minutes))
1378
+ return overviewPage(session, 400, 'Choose how far from the list.');
1379
+ const moved = await sandbox.fastForward(tenantRef, minutes);
1380
+ return overviewPage(session, 200, `${String(moved.holds)} holds and ${String(moved.approvals)} approvals lapsed.`);
1381
+ }
1382
+ catch (error) {
1383
+ if (error.code === 'sandbox_not_empty') {
1384
+ return overviewPage(session, 409, 'Test has resources already. Start again first.');
1385
+ }
1386
+ throw error;
1387
+ }
1388
+ }
990
1389
  function endpoints() {
991
1390
  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>`;
992
1391
  }
1392
+ const accessOf = (key) => key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only';
993
1393
  async function keysPage(session, status = 200, notice) {
994
- const keys = await options.keys.list({ tenantRef: tenantOf(session) });
1394
+ const tenantRef = tenantOf(session);
1395
+ const grouping = options.resources.grouping;
1396
+ const [keys, locations, groups] = await Promise.all([
1397
+ options.keys.list({ tenantRef }),
1398
+ grouping?.locations(tenantRef),
1399
+ grouping?.groups(tenantRef),
1400
+ ]);
995
1401
  const token = csrfToken(session);
1402
+ const paths = locationPaths(locations ?? []);
1403
+ // A key limited to locations or groups (RFC 0003 G6), when the tenant has any to choose.
1404
+ const limitChoices = (locations?.length ?? 0) + (groups?.length ?? 0) > 0
1405
+ ? 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>`
1406
+ : html ``;
996
1407
  const content = html `${noticeOf(notice)}
997
1408
  ${pageHead('API keys')}
998
1409
  <section class="panel">
@@ -1009,19 +1420,39 @@ ${endpoints()}
1009
1420
  <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>
1010
1421
  <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>
1011
1422
  <button class="primary" type="submit">Create key</button>
1423
+ ${limitChoices}
1424
+ <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>
1012
1425
  </form>
1013
1426
  </section>
1014
1427
  ${keys.length === 0
1015
1428
  ? emptyState(ICONS.keys, 'No keys yet. Give every agent its own, with the least access it needs.', { href: '#key-name', label: 'Create a key' })
1016
- : 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>
1429
+ : 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>
1017
1430
  <tbody>${keyRows(keys, token, session)}</tbody></table></div>`}`;
1018
1431
  return respond(status, layout('API keys', content, session, 'keys'));
1019
1432
  }
1433
+ /**
1434
+ * How many resources the tenant may have here, and how many it has as that limit counts them: the
1435
+ * dashboard's own limit, or its plan's when lower (archived resources count against a plan).
1436
+ */
1437
+ async function resourceAllowance(tenantRef, resources) {
1438
+ const plan = options.billing ? await options.billing.plan(tenantRef) : null;
1439
+ if (!plan || plan.limits.resources >= SLOTLOCK_DASHBOARD_MAX_RESOURCES) {
1440
+ return { used: resources.length, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES, planned: false, archived: 0 };
1441
+ }
1442
+ const counted = options.resources.count ? await options.resources.count(tenantRef) : resources.length;
1443
+ return {
1444
+ used: counted,
1445
+ limit: plan.limits.resources,
1446
+ planned: true,
1447
+ archived: Math.max(0, counted - resources.length),
1448
+ };
1449
+ }
1020
1450
  async function resourcesPage(session, status = 200, notice) {
1021
1451
  const resources = await options.resources.list(tenantOf(session));
1452
+ const allowance = await resourceAllowance(tenantOf(session), resources);
1022
1453
  const token = csrfToken(session);
1023
1454
  const content = html `${noticeOf(notice)}
1024
- ${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : undefined)}
1455
+ ${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>`)}
1025
1456
  <section class="panel" id="add-resource">
1026
1457
  <h2>Add a resource</h2>
1027
1458
  <form class="create" method="post" action="${root}/resources">
@@ -1031,19 +1462,92 @@ ${pageHead('Resources', resources.length > 0 ? html `<a class="button" href="${r
1031
1462
  <label>Time zone<input name="timezone" required maxlength="64" value="UTC" placeholder="Europe/London"></label>
1032
1463
  <button class="primary" type="submit">Add resource</button>
1033
1464
  </form>
1034
- <p class="fine">${String(resources.length)} of ${String(SLOTLOCK_DASHBOARD_MAX_RESOURCES)}. Adding a reference you have changes its time zone.</p>
1465
+ <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>
1035
1466
  </section>
1036
1467
  ${resources.length === 0
1037
1468
  ? emptyState(ICONS.resources, 'No resources yet. Agents can only book what is listed here.', { href: '#reference', label: 'Add a resource' })
1038
- : 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>`}`;
1469
+ : 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>`}`;
1039
1470
  return respond(status, layout('Resources', content, session, 'resources'));
1040
1471
  }
1041
- async function settingsPage(session, status = 200, notice) {
1472
+ /** The tenant's locations and groups (RFC 0003 G2, G3): what they are, and adding and changing them. */
1473
+ async function locationsPage(session, status = 200, notice) {
1474
+ const grouping = options.resources.grouping;
1475
+ if (!grouping)
1476
+ return nothingHere(session);
1477
+ const tenantRef = tenantOf(session);
1478
+ const [locations, groups] = await Promise.all([grouping.locations(tenantRef), grouping.groups(tenantRef)]);
1479
+ const token = csrfToken(session);
1480
+ const byRef = new Map(locations.map((location) => [location.ref, location]));
1481
+ const paths = locationPaths(locations);
1482
+ const sent = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
1483
+ // Where a location may be: nowhere, or any location but itself and those inside it.
1484
+ const insideChoices = (selected, except) => html `<option value=""${selected === null ? html ` selected` : html ``}>Nowhere (at the top)</option>${locations
1485
+ .filter((location) => except === undefined || !locationWithin(byRef, location.ref, except))
1486
+ .map((location) => html `<option value="${location.ref}"${location.ref === selected ? html ` selected` : html ``}>${paths.get(location.ref) ?? location.name}</option>`)}`;
1487
+ const nameLimit = String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.name);
1488
+ const locationRows = locations.map((location) => {
1489
+ const parent = location.parentRef === null ? undefined : paths.get(location.parentRef);
1490
+ const zone = locationZone(byRef, location);
1491
+ return html `<tr>
1492
+ <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>
1493
+ <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>
1494
+ <td data-label="Resources">${String(location.resources)}</td>
1495
+ <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}">
1496
+ <label>Name<input name="name" required maxlength="${nameLimit}" value="${location.name}"></label>
1497
+ <label>Inside<select name="parent">${insideChoices(location.parentRef, location.ref)}</select></label>
1498
+ <label>Time zone<input name="timezone" maxlength="100" value="${location.timezone ?? ''}" placeholder="As where it is"></label>
1499
+ <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>
1500
+ <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>
1501
+ </tr>`;
1502
+ });
1503
+ const groupRows = groups.map((group) => html `<tr>
1504
+ <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>
1505
+ <td data-label="Description" class="wrap">${group.description ?? ''}</td>
1506
+ <td data-label="Resources">${String(group.members)}</td>
1507
+ <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}">
1508
+ <label>Name<input name="name" required maxlength="${nameLimit}" value="${group.name}"></label>
1509
+ <label>Description<textarea name="description" rows="2" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}">${group.description ?? ''}</textarea></label>
1510
+ <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>
1511
+ <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>
1512
+ </tr>`);
1513
+ const content = html `${noticeOf(notice)}
1514
+ <p><a class="back" href="${root}/resources">Resources</a></p>
1515
+ ${pageHead('Locations and groups')}
1516
+ <section class="panel" id="locations">
1517
+ <h2>Locations</h2>
1518
+ <form class="create" method="post" action="${root}/locations">${sent()}
1519
+ <label>Reference<input id="location-ref" name="ref" required maxlength="100" placeholder="floor-3"></label>
1520
+ <label class="grow">Name<input name="name" required maxlength="${nameLimit}" placeholder="Floor 3"></label>
1521
+ <label>Inside<select name="parent">${insideChoices(null)}</select></label>
1522
+ <label>Time zone<input name="timezone" maxlength="100" placeholder="As where it is"></label>
1523
+ <button class="primary" type="submit">Add location</button>
1524
+ </form>
1525
+ <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>
1526
+ ${locations.length === 0
1527
+ ? html `<p class="empty">No locations yet.</p>`
1528
+ : 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>`}
1529
+ </section>
1530
+ <section class="panel" id="groups">
1531
+ <h2>Groups</h2>
1532
+ <form class="create" method="post" action="${root}/groups">${sent()}
1533
+ <label>Reference<input id="group-ref" name="ref" required maxlength="100" placeholder="video-rooms"></label>
1534
+ <label class="grow">Name<input name="name" required maxlength="${nameLimit}" placeholder="Rooms with video"></label>
1535
+ <label class="grow">Description<input name="description" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}" placeholder="Optional"></label>
1536
+ <button class="primary" type="submit">Add group</button>
1537
+ </form>
1538
+ <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>
1539
+ ${groups.length === 0
1540
+ ? html `<p class="empty">No groups yet.</p>`
1541
+ : 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>`}
1542
+ </section>`;
1543
+ return respond(status, layout('Locations and groups', content, session, 'resources'));
1544
+ }
1545
+ async function settingsPage(session, status = 200, notice, draft) {
1042
1546
  const rules = options.availability ?? [];
1043
1547
  const current = session.orgs?.find((org) => org.id === session.org);
1044
1548
  const content = html `${noticeOf(notice)}
1045
1549
  ${pageHead('Settings')}
1046
- ${await assistantSettingsPanel(session)}
1550
+ ${await assistantSettingsPanel(session, draft)}
1047
1551
  <section class="panel">
1048
1552
  <h2>Endpoints</h2>
1049
1553
  ${endpoints()}
@@ -1057,7 +1561,7 @@ ${rules.length === 0
1057
1561
  </section>
1058
1562
  <section class="panel">
1059
1563
  <h2>Account</h2>
1060
- <dl class="facts"><dt>Signed in as</dt><dd>@${session.login}</dd>${current
1564
+ <dl class="facts"><dt>Signed in as</dt><dd>${shownAs(session)}</dd>${current
1061
1565
  ? 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 ``}`
1062
1566
  : html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
1063
1567
  ${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
@@ -1072,12 +1576,21 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
1072
1576
  return notFound(session);
1073
1577
  const start = new Date(now());
1074
1578
  const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
1075
- const [agenda, changes, keys, defaults] = await Promise.all([
1579
+ const grouping = options.resources.grouping;
1580
+ const [agenda, changes, keys, defaults, locations, groups, everyResource] = await Promise.all([
1076
1581
  options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
1077
1582
  options.resources.recentChanges?.(tenantRef, 100),
1078
1583
  options.keys.list({ tenantRef }),
1079
1584
  options.resources.ruleDefaults?.(tenantRef),
1585
+ grouping?.locations(tenantRef),
1586
+ grouping?.groups(tenantRef),
1587
+ // For a composite: its parts, or the wholes it can be a part of.
1588
+ options.resources.setParent ? options.resources.list(tenantRef) : undefined,
1080
1589
  ]);
1590
+ const parts = (everyResource?.filter((other) => other.parentId === resource.id) ?? []).sort((one, other) => (one.partIndex ?? 0) - (other.partIndex ?? 0));
1591
+ const wholes = everyResource?.filter((other) => other.id !== resource.id && other.parentId === undefined && other.archivedAt === undefined) ?? [];
1592
+ const paths = locationPaths(locations ?? []);
1593
+ const groupNames = new Map((groups ?? []).map((group) => [group.ref, group.name]));
1081
1594
  const byId = new Map([[resource.id, resource]]);
1082
1595
  const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
1083
1596
  const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
@@ -1102,7 +1615,8 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
1102
1615
  const content = html `${noticeOf(notice)}
1103
1616
  <p><a class="back" href="${root}/resources">Resources</a></p>
1104
1617
  ${pageHead(resourceName(resource, resource.id))}
1105
- <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>
1618
+ ${resource.description ? html `<p>${resource.description}</p>` : html ``}
1619
+ <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>
1106
1620
  <div class="split">
1107
1621
  <div class="stack">
1108
1622
  ${options.resources.setRules
@@ -1127,6 +1641,25 @@ ${inheritedNote(inherited)}
1127
1641
  <p class="fine">Leave a field empty for no rule${inherited.size > 0 ? ' of its own' : ''}.</p>
1128
1642
  <button class="primary" type="submit">Save rules</button>
1129
1643
  </form>
1644
+ </section>`
1645
+ : html ``}
1646
+ ${options.resources.setProfile
1647
+ ? html `<section class="panel">
1648
+ <h2>Profile</h2>
1649
+ <form method="post" action="${root}/resources/profile">
1650
+ <input type="hidden" name="csrf" value="${token}">
1651
+ <input type="hidden" name="once" value="${formNonce(session)}">
1652
+ <input type="hidden" name="id" value="${resource.id}">
1653
+ <div class="grid-form">
1654
+ <label>Name<input name="name" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.name)}" value="${resource.name ?? ''}" placeholder="${resource.externalRef ?? ''}"></label>
1655
+ <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>
1656
+ <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>
1657
+ <label>Labels<input name="labels" value="${(resource.labels ?? []).join(', ')}" placeholder="video, accessible"></label>
1658
+ </div>
1659
+ <label>Description<textarea name="description" rows="3" maxlength="${String(SLOTLOCK_RESOURCE_PROFILE_LIMITS.description)}">${resource.description ?? ''}</textarea></label>
1660
+ <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>
1661
+ <button class="primary" type="submit">Save profile</button>
1662
+ </form>
1130
1663
  </section>`
1131
1664
  : html ``}
1132
1665
  ${options.resources.setPool
@@ -1140,6 +1673,39 @@ ${options.resources.setPool
1140
1673
  <button type="submit">Save pool</button>
1141
1674
  </form>
1142
1675
  <p class="fine">Agents can book any free member of a pool. Leave it empty for none.</p>
1676
+ </section>`
1677
+ : html ``}
1678
+ ${options.resources.setParent && everyResource
1679
+ ? html `<section class="panel" id="composite">
1680
+ <h2>Composite</h2>
1681
+ ${parts.length > 0
1682
+ ? 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>`
1683
+ : html `<form class="create" method="post" action="${root}/resources/parent">
1684
+ <input type="hidden" name="csrf" value="${token}">
1685
+ <input type="hidden" name="once" value="${formNonce(session)}">
1686
+ <input type="hidden" name="id" value="${resource.id}">
1687
+ <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>
1688
+ <button type="submit">Save</button>
1689
+ </form>
1690
+ <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>`}
1691
+ </section>`
1692
+ : html ``}
1693
+ ${grouping && locations && groups
1694
+ ? html `<section class="panel" id="place">
1695
+ <h2>Location and groups</h2>
1696
+ ${locations.length === 0 && groups.length === 0
1697
+ ? html `<p class="empty">No locations or groups yet. <a href="${root}/locations">Add them</a>, then choose them here.</p>`
1698
+ : html `<form method="post" action="${root}/resources/place">
1699
+ <input type="hidden" name="csrf" value="${token}">
1700
+ <input type="hidden" name="once" value="${formNonce(session)}">
1701
+ <input type="hidden" name="id" value="${resource.id}">
1702
+ <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>
1703
+ ${groups.length > 0
1704
+ ? 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>`
1705
+ : html ``}
1706
+ <button class="primary" type="submit">Save location and groups</button>
1707
+ </form>
1708
+ <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>`}
1143
1709
  </section>`
1144
1710
  : html ``}
1145
1711
  </div>
@@ -1187,6 +1753,153 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1187
1753
  }
1188
1754
  return redirect(303, `${root}/resources/${id}`);
1189
1755
  }
1756
+ async function saveProfile(session, form) {
1757
+ const id = form.get('id') ?? '';
1758
+ const resource = await ownResource(session, id);
1759
+ if (!resource || !options.resources.setProfile)
1760
+ return notFound(session);
1761
+ const text = (key) => optionalField(form, key);
1762
+ const capacity = text('capacity');
1763
+ if (capacity !== null && !/^\d{1,6}$/.test(capacity)) {
1764
+ return resourceDetailPage(session, id, 400, PROFILE_PROBLEMS.capacity);
1765
+ }
1766
+ const kind = form.get('kind') ?? 'other';
1767
+ try {
1768
+ await options.resources.setProfile(tenantOf(session), id, {
1769
+ name: text('name'),
1770
+ description: text('description'),
1771
+ kind: kind,
1772
+ capacity: capacity === null ? null : Number(capacity),
1773
+ // Commas or spaces between them; a person may type them in capitals.
1774
+ labels: (form.get('labels') ?? '')
1775
+ .split(/[\s,]+/)
1776
+ .map((label) => label.trim().toLowerCase())
1777
+ .filter((label) => label !== ''),
1778
+ }, personOf(session));
1779
+ }
1780
+ catch (error) {
1781
+ if (error.code !== 'invalid_resource_profile')
1782
+ throw error;
1783
+ const field = error.field;
1784
+ return resourceDetailPage(session, id, 400, PROFILE_PROBLEMS[typeof field === 'string' && field in PROFILE_PROBLEMS ? field : 'other']);
1785
+ }
1786
+ return redirect(303, `${root}/resources/${id}`);
1787
+ }
1788
+ /** Text from a form, trimmed, or null when empty. */
1789
+ function optionalField(form, key) {
1790
+ const value = (form.get(key) ?? '').trim();
1791
+ return value === '' ? null : value;
1792
+ }
1793
+ async function saveLocation(session, form) {
1794
+ const grouping = options.resources.grouping;
1795
+ if (!grouping)
1796
+ return nothingHere(session);
1797
+ try {
1798
+ await grouping.putLocation(tenantOf(session), {
1799
+ // A person may type a reference in capitals; references are lowercase.
1800
+ ref: (form.get('ref') ?? '').trim().toLowerCase(),
1801
+ name: (form.get('name') ?? '').trim(),
1802
+ parentRef: optionalField(form, 'parent'),
1803
+ timezone: optionalField(form, 'timezone'),
1804
+ }, personOf(session));
1805
+ }
1806
+ catch (error) {
1807
+ const problem = groupingProblem(error);
1808
+ if (!problem)
1809
+ throw error;
1810
+ return locationsPage(session, problem.status, problem.text);
1811
+ }
1812
+ return redirect(303, `${root}/locations`);
1813
+ }
1814
+ async function saveGroup(session, form) {
1815
+ const grouping = options.resources.grouping;
1816
+ if (!grouping)
1817
+ return nothingHere(session);
1818
+ try {
1819
+ await grouping.putGroup(tenantOf(session), {
1820
+ ref: (form.get('ref') ?? '').trim().toLowerCase(),
1821
+ name: (form.get('name') ?? '').trim(),
1822
+ description: optionalField(form, 'description'),
1823
+ }, personOf(session));
1824
+ }
1825
+ catch (error) {
1826
+ const problem = groupingProblem(error);
1827
+ if (!problem)
1828
+ throw error;
1829
+ return locationsPage(session, problem.status, problem.text);
1830
+ }
1831
+ return redirect(303, `${root}/locations#groups`);
1832
+ }
1833
+ async function deleteGrouping(session, form, kind) {
1834
+ const grouping = options.resources.grouping;
1835
+ if (!grouping)
1836
+ return nothingHere(session);
1837
+ const ref = form.get('ref') ?? '';
1838
+ let deleted;
1839
+ try {
1840
+ deleted =
1841
+ kind === 'location'
1842
+ ? await grouping.deleteLocation(tenantOf(session), ref, personOf(session))
1843
+ : await grouping.deleteGroup(tenantOf(session), ref, personOf(session));
1844
+ }
1845
+ catch (error) {
1846
+ const problem = groupingProblem(error);
1847
+ if (!problem)
1848
+ throw error;
1849
+ return locationsPage(session, problem.status, problem.text);
1850
+ }
1851
+ if (!deleted) {
1852
+ const problem = GROUPING_PROBLEMS[`${kind}_not_found`];
1853
+ return locationsPage(session, problem.status, problem.text);
1854
+ }
1855
+ return redirect(303, `${root}/locations${kind === 'group' ? '#groups' : ''}`);
1856
+ }
1857
+ async function savePlace(session, form) {
1858
+ const id = form.get('id') ?? '';
1859
+ const resource = await ownResource(session, id);
1860
+ const grouping = options.resources.grouping;
1861
+ if (!resource || !grouping)
1862
+ return notFound(session);
1863
+ const groups = [...new Set(form.getAll('group'))];
1864
+ if (groups.length > SLOTLOCK_MAX_GROUPS_PER_RESOURCE) {
1865
+ const problem = GROUPING_PROBLEMS.resource_group_limit_reached;
1866
+ return resourceDetailPage(session, id, problem.status, problem.text);
1867
+ }
1868
+ try {
1869
+ const placed = await grouping.place(tenantOf(session), id, { location: optionalField(form, 'location'), groups }, personOf(session));
1870
+ if (!placed)
1871
+ return notFound(session);
1872
+ }
1873
+ catch (error) {
1874
+ const problem = groupingProblem(error);
1875
+ if (!problem)
1876
+ throw error;
1877
+ return resourceDetailPage(session, id, problem.status, problem.text);
1878
+ }
1879
+ return redirect(303, `${root}/resources/${id}`);
1880
+ }
1881
+ async function saveParent(session, form) {
1882
+ const id = form.get('id') ?? '';
1883
+ const resource = await ownResource(session, id);
1884
+ if (!resource || !options.resources.setParent)
1885
+ return notFound(session);
1886
+ const wanted = (form.get('parent') ?? '').trim();
1887
+ const parent = wanted === '' ? null : await ownResource(session, wanted);
1888
+ if (wanted !== '' && !parent)
1889
+ return resourceDetailPage(session, id, 400, 'That is not one of your resources.');
1890
+ try {
1891
+ const changed = await options.resources.setParent(tenantOf(session), id, parent?.id ?? null, personOf(session));
1892
+ if (!changed)
1893
+ return notFound(session);
1894
+ }
1895
+ catch (error) {
1896
+ const text = COMPOSITE_PROBLEMS[String(error?.code)];
1897
+ if (!text)
1898
+ throw error;
1899
+ return resourceDetailPage(session, id, 409, text);
1900
+ }
1901
+ return redirect(303, `${root}/resources/${id}`);
1902
+ }
1190
1903
  async function savePool(session, form) {
1191
1904
  const id = form.get('id') ?? '';
1192
1905
  const resource = await ownResource(session, id);
@@ -1203,6 +1916,17 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1203
1916
  }
1204
1917
  return redirect(303, `${root}/resources/${id}`);
1205
1918
  }
1919
+ /** An endpoint's filter (set over the API), in a few words. */
1920
+ function webhookFilterText(filter) {
1921
+ return [
1922
+ filter.resourceIds ? `${filter.resourceIds.length} resource${filter.resourceIds.length === 1 ? '' : 's'}` : null,
1923
+ filter.pools ? `pools ${filter.pools.join(', ')}` : null,
1924
+ filter.locations ? `locations ${filter.locations.join(', ')}` : null,
1925
+ filter.groups ? `groups ${filter.groups.join(', ')}` : null,
1926
+ ]
1927
+ .filter((part) => part !== null)
1928
+ .join('; ');
1929
+ }
1206
1930
  function endpointState(endpoint) {
1207
1931
  if (endpoint.disabledAt)
1208
1932
  return { text: 'Disabled', tone: 'revoked' };
@@ -1227,7 +1951,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1227
1951
  : '';
1228
1952
  return html `<tr>
1229
1953
  <td data-label="URL" class="strong"><code class="truncate url" title="${endpoint.url}">${endpoint.url}</code></td>
1230
- <td data-label="Events" class="wrap">${endpoint.eventTypes ? endpoint.eventTypes.join(', ') : 'All'}</td>
1954
+ <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>
1231
1955
  <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>
1232
1956
  <td data-label="Added">${dateCell(endpoint.createdAt)}</td>
1233
1957
  <td class="actions">${endpoint.disabledAt
@@ -1267,7 +1991,7 @@ ${endpoints.length === 0
1267
1991
  const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
1268
1992
  if (!created.ok) {
1269
1993
  const message = created.code === 'webhook_limit_reached'
1270
- ? `You have ${String(created.limit)} webhooks, as many as Slotlock allows. Delete one first.`
1994
+ ? `You have ${String(created.limit)} webhooks, as many as ${options.billing ? 'your plan' : 'Slotlock'} allows. Delete one first${options.billing ? ', or see Plan' : ''}.`
1271
1995
  : created.code === 'invalid_webhook_url'
1272
1996
  ? 'Give an https URL, with no user name, password or #fragment.'
1273
1997
  : 'Choose events from the list.';
@@ -1356,6 +2080,87 @@ ${entries.length === 0
1356
2080
  }
1357
2081
  return redirect(303, `${root}/waitlist`);
1358
2082
  }
2083
+ // ---- Approvals (RFC 0004 A4) ----
2084
+ async function approvalsPage(session, status = 200, notice) {
2085
+ const approvals = options.resources.approvals;
2086
+ if (!approvals)
2087
+ return notFound(session);
2088
+ const tenantRef = tenantOf(session);
2089
+ const [entries, resources] = await Promise.all([
2090
+ approvals.list(tenantRef, APPROVALS_SHOWN),
2091
+ options.resources.list(tenantRef),
2092
+ ]);
2093
+ const byId = new Map(resources.map((resource) => [resource.id, resource]));
2094
+ const token = csrfToken(session);
2095
+ const pending = entries.filter((entry) => entry.status === 'pending' && entry.expiresAt.getTime() > now());
2096
+ const decided = entries.filter((entry) => !pending.includes(entry));
2097
+ const what = (entry) => entry.resourceIds.map((id) => resourceName(byId.get(id), id)).join(', ');
2098
+ const when = (entry) => {
2099
+ const zone = byId.get(entry.resourceIds[0] ?? '')?.timezone ?? 'UTC';
2100
+ const day = dayLabel(entry.start, zone, now());
2101
+ return `${day.label} ${clock(entry.start, zone)}–${clock(entry.end, zone)}`;
2102
+ };
2103
+ const KINDS = {
2104
+ slotlock_create_event: 'Booking',
2105
+ slotlock_book_together: 'Bundle',
2106
+ slotlock_update_event: 'Move',
2107
+ };
2108
+ 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>`;
2109
+ const pendingRows = pending.map((entry) => html `<tr>
2110
+ <td data-label="When" class="strong">${when(entry)}</td>
2111
+ <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>
2112
+ <td data-label="Asked by"><code class="truncate" title="${entry.requestedBy ?? ''}">${entry.requestedBy ?? 'unknown'}</code></td>
2113
+ <td data-label="Decide by">${clock(entry.expiresAt, 'UTC')} UTC, ${dayLabel(entry.expiresAt, 'UTC', now()).label}</td>
2114
+ <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>
2115
+ </tr>`);
2116
+ const STATUS = {
2117
+ pending: { text: 'Lapsed', tone: 'revoked' },
2118
+ granted: { text: 'Approved', tone: 'active' },
2119
+ declined: { text: 'Declined', tone: 'revoked' },
2120
+ expired: { text: 'Expired', tone: 'revoked' },
2121
+ };
2122
+ const decidedRows = decided.map((entry) => html `<tr>
2123
+ <td data-label="When" class="strong">${when(entry)}</td>
2124
+ <td data-label="What"><span class="truncate" title="${what(entry)}">${what(entry)}</span></td>
2125
+ <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>
2126
+ <td data-label="Decided by"><code class="truncate" title="${entry.decidedBy ?? ''}">${entry.decidedBy ?? '—'}</code></td>
2127
+ <td data-label="Asked">${ago(entry.createdAt, now())}</td>
2128
+ </tr>`);
2129
+ const content = html `${noticeOf(notice)}
2130
+ ${pageHead('Approvals')}
2131
+ <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>
2132
+ <div class="stats">
2133
+ <div class="stat"><span class="stat-label">Waiting for you</span><span class="stat-value">${String(pending.length)}</span></div>
2134
+ </div>
2135
+ ${pending.length === 0
2136
+ ? emptyState(ICONS.approvals, 'Nothing is waiting. A policy that holds bookings for a person sends them here.')
2137
+ : 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>`}
2138
+ ${decided.length === 0
2139
+ ? html ``
2140
+ : 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>`}`;
2141
+ return respond(status, layout('Approvals', content, session, 'approvals'));
2142
+ }
2143
+ async function decideApproval(session, form) {
2144
+ const approvals = options.resources.approvals;
2145
+ if (!approvals)
2146
+ return notFound(session);
2147
+ const decision = form.get('decision');
2148
+ if (decision !== 'grant' && decision !== 'decline')
2149
+ return approvalsPage(session, 400, 'Choose approve or decline.');
2150
+ const reason = (form.get('reason') ?? '').trim().slice(0, 500);
2151
+ const decided = await approvals.decide(tenantOf(session), form.get('id') ?? '', decision, personOf(session), reason || undefined);
2152
+ if (!decided.ok) {
2153
+ const messages = {
2154
+ approval_not_found: 'That approval is not one of yours.',
2155
+ approval_not_pending: 'That approval was already decided.',
2156
+ approval_expired: 'The wait for that approval ended: its time is no longer held.',
2157
+ overlap: 'That time is taken now, so it cannot be booked. Decline it instead.',
2158
+ revision_conflict: 'The agent changed that event since it asked. Decline this one; it can ask again.',
2159
+ };
2160
+ return approvalsPage(session, 409, messages[decided.code] ?? `That booking cannot be made now (${decided.code}). Decline it instead.`);
2161
+ }
2162
+ return redirect(303, `${root}/approvals`);
2163
+ }
1359
2164
  // ---- The assistant ----
1360
2165
  const assistantOptions = options.assistant;
1361
2166
  /** The assistant's records, when the assistant is on and the adapter keeps them. Per request (Workers). */
@@ -1365,6 +2170,11 @@ ${entries.length === 0
1365
2170
  const resolveHost = assistantOptions?.resolve ??
1366
2171
  (async (hostname) => (await dnsLookup(hostname, { all: true })).map(({ address }) => address));
1367
2172
  const PRIVATE_URL_REFUSAL = 'That URL is, or resolves to, a local or private network address (loopback, private, link-local, shared, unique-local or cloud metadata), which this server does not reach. For a model on its own machine or network, the server’s operator can allow them with SLOTLOCK_ASSISTANT_PRIVATE_URLS=on.';
2173
+ // The dashboard's own host and the server's: a model there would be sent the organization's key
2174
+ // and the conversation by this deployment itself, on a public name the private-address check passes.
2175
+ const ownHosts = new Set([publicUrl.hostname, service.hostname]);
2176
+ const namesOwnHost = (baseUrl) => ownHosts.has(new URL(baseUrl).hostname);
2177
+ const OWN_HOST_REFUSAL = 'That URL is this Slotlock deployment’s own address. Give the address of the model’s API.';
1368
2178
  const assistantTimeout = assistantOptions?.timeoutMs ?? 30_000;
1369
2179
  const assistantTurnTimeout = assistantOptions?.turnTimeoutMs ?? 90_000;
1370
2180
  const conversationTtl = assistantOptions?.conversationTtlMs ?? 2 * 3_600_000;
@@ -1374,6 +2184,23 @@ ${entries.length === 0
1374
2184
  if (assistantOptions && assistantOptions.secret === options.sessionSecret) {
1375
2185
  throw new Error('Slotlock dashboard assistant secret must differ from the session secret');
1376
2186
  }
2187
+ const previousSecrets = [...(assistantOptions?.previousSecrets ?? [])];
2188
+ if (previousSecrets.length > SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS) {
2189
+ throw new Error(`Slotlock dashboard assistant takes at most ${String(SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS)} previous secrets`);
2190
+ }
2191
+ for (const previous of previousSecrets) {
2192
+ if (Buffer.byteLength(previous, 'utf8') < 32) {
2193
+ throw new Error('Slotlock dashboard assistant previous secrets must each be at least 32 bytes');
2194
+ }
2195
+ if (previous === assistantOptions?.secret || previous === options.sessionSecret) {
2196
+ throw new Error('Slotlock dashboard assistant previous secrets must differ from the assistant and session secrets');
2197
+ }
2198
+ }
2199
+ if (new Set(previousSecrets).size !== previousSecrets.length) {
2200
+ throw new Error('Slotlock dashboard assistant previous secrets must differ from each other');
2201
+ }
2202
+ /** The current secret, then the previous ones: a stored key is tried with each in turn. */
2203
+ const assistantSecrets = assistantOptions ? [assistantOptions.secret, ...previousSecrets] : [];
1377
2204
  const memberMessages = createSlotlockRateLimiter(assistantOptions?.rate ?? { perSecond: 10 / 60, burst: 10 }, now);
1378
2205
  const toolTitles = new Map(slotlockAgentTools().map((tool) => [tool.name, tool.title]));
1379
2206
  /** The writes that take an idempotency key: the dashboard sets it, from the action's id. */
@@ -1398,7 +2225,7 @@ ${entries.length === 0
1398
2225
  * Who the assistant acts as, for the record and for ownership: the member by GitHub user id,
1399
2226
  * which a rename does not change. Shown as "Assistant for @login".
1400
2227
  */
1401
- const assistantActor = (session) => `dashboard-assistant:github:${session.uid}`;
2228
+ const assistantActor = (session) => `dashboard-assistant:${personOf(session)}`;
1402
2229
  const CONFIRMED_WRITES = new Set([
1403
2230
  'slotlock_create_event',
1404
2231
  'slotlock_update_event',
@@ -1432,6 +2259,15 @@ ${entries.length === 0
1432
2259
  else if (operation === 'slotlock_release_slots') {
1433
2260
  text = `Release the hold ${String(input.hold_id)} and every slot held with it.`;
1434
2261
  }
2262
+ else if (operation === 'slotlock_confirm_slot') {
2263
+ text = `Book the time held by ${String(input.hold_id)}${input.title ? `, as "${String(input.title)}"` : ''}, and release the other holds made with it.`;
2264
+ }
2265
+ else if (operation === 'slotlock_reschedule') {
2266
+ 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)}` : ''}.`;
2267
+ }
2268
+ else if (operation === 'slotlock_cancel') {
2269
+ text = `Cancel the event ${String(input.event_id)}.`;
2270
+ }
1435
2271
  else {
1436
2272
  text = '';
1437
2273
  }
@@ -1439,7 +2275,8 @@ ${entries.length === 0
1439
2275
  return text.length > 500 ? `${text.slice(0, 500)}…` : text;
1440
2276
  }
1441
2277
  /** Owners of the organization in use may change its assistant; a person always may their own. */
1442
- const isAdmin = (session) => !served || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
2278
+ /** An owner of the workspace: the person in their own, an organization's owner in its. */
2279
+ const isAdmin = (session) => session.org === undefined || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
1443
2280
  const assistantPrincipal = (session) => ({
1444
2281
  subject: assistantActor(session),
1445
2282
  tenantRef: tenantOf(session),
@@ -1477,6 +2314,8 @@ ${entries.length === 0
1477
2314
  ? { consumeRateLimit: assistant.consumeRateLimit }
1478
2315
  : {}),
1479
2316
  ...(confirm ? { confirm } : {}),
2317
+ // A proposal halts at `confirm` and never reaches the backend, so only what runs counts.
2318
+ ...(assistant.onUsage ? { onUsage: assistant.onUsage } : {}),
1480
2319
  onError: (error) => {
1481
2320
  try {
1482
2321
  ignoreRejection(options.onError?.(error));
@@ -1493,18 +2332,36 @@ ${entries.length === 0
1493
2332
  const settings = store ? await store.getSettings(tenantOf(session)) : null;
1494
2333
  if (!settings || !assistantOptions)
1495
2334
  return null;
2335
+ if (namesOwnHost(settings.baseUrl))
2336
+ return { problem: OWN_HOST_REFUSAL };
1496
2337
  if (!slotlockAssistantUrlAllowed(settings.baseUrl, privateUrls) ||
1497
2338
  !(await checkSlotlockAssistantHost(settings.baseUrl, { privateUrls, resolve: resolveHost }))) {
1498
2339
  return { problem: PRIVATE_URL_REFUSAL };
1499
2340
  }
1500
2341
  let apiKey = null;
1501
2342
  if (settings.sealedKey !== null) {
1502
- apiKey = await openSlotlockAssistantKey(assistantOptions.secret, tenantOf(session), settings.sealedKey);
1503
- if (apiKey === null) {
2343
+ const tenantRef = tenantOf(session);
2344
+ const opened = await openSlotlockAssistantKeyWithAny(assistantSecrets, tenantRef, settings.sealedKey);
2345
+ if (opened === null) {
1504
2346
  return {
1505
2347
  problem: 'The stored API key cannot be opened: the server’s assistant secret changed. An admin can enter the key again in Settings.',
1506
2348
  };
1507
2349
  }
2350
+ apiKey = opened.apiKey;
2351
+ // Sealed under a previous secret: sealed again under the current one, unless an owner saved
2352
+ // another key meanwhile. Best effort: the key opened either way.
2353
+ if (opened.secretIndex > 0 && store?.resealKey) {
2354
+ try {
2355
+ const resealed = await sealSlotlockAssistantKey(assistantOptions.secret, tenantRef, apiKey);
2356
+ await store.resealKey(tenantRef, settings.sealedKey, resealed);
2357
+ }
2358
+ catch (error) {
2359
+ // A suspended tenant reads and writes nothing, this included: it opens the old way.
2360
+ if (error?.code !== 'tenant_suspended') {
2361
+ ignoreRejection(options.onError?.(error));
2362
+ }
2363
+ }
2364
+ }
1508
2365
  }
1509
2366
  return {
1510
2367
  model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
@@ -1608,7 +2465,7 @@ ${settings
1608
2465
  : 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
1609
2466
  return respond(status, layout('Assistant', content, session, 'assistant'));
1610
2467
  }
1611
- async function assistantSettingsPanel(session) {
2468
+ async function assistantSettingsPanel(session, draft) {
1612
2469
  const store = assistantStore();
1613
2470
  if (!store)
1614
2471
  return html ``;
@@ -1620,21 +2477,29 @@ ${settings
1620
2477
  : html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
1621
2478
  }
1622
2479
  const token = csrfToken(session);
1623
- const selected = (provider) => (settings?.provider === provider ? html ` selected` : html ``);
2480
+ // What the form shows: a draft (a preset, or a model list) over what is saved.
2481
+ const provider = draft?.provider ?? settings?.provider;
2482
+ const baseUrl = draft ? draft.baseUrl : (settings?.baseUrl ?? '');
2483
+ const model = draft?.model ?? settings?.model ?? '';
2484
+ const selected = (value) => (provider === value ? html ` selected` : html ``);
2485
+ const models = draft?.models ?? [];
1624
2486
  return html `<section class="panel" id="assistant">
1625
2487
  <h2>Assistant</h2>
2488
+ <form class="presets" method="get" action="${root}/settings"><span class="fine">Fill in for</span>${SLOTLOCK_ASSISTANT_PRESETS.map((preset) => html `<button type="submit" name="preset" value="${preset.id}">${preset.name}</button>`)}</form>
1626
2489
  <form method="post" action="${root}/settings/assistant">
1627
2490
  <input type="hidden" name="csrf" value="${token}">
1628
2491
  <input type="hidden" name="once" value="${formNonce(session)}">
1629
2492
  <div class="grid-form">
1630
2493
  <label>Provider<select name="provider"><option value="anthropic"${selected('anthropic')}>Anthropic Messages API</option><option value="openai"${selected('openai')}>OpenAI-compatible</option></select></label>
1631
- <label>Base URL<input name="base_url" required maxlength="2048" value="${settings?.baseUrl ?? ''}" placeholder="https://api.anthropic.com"></label>
1632
- <label>Model<input name="model" required maxlength="200" value="${settings?.model ?? ''}" placeholder="your-model"></label>
2494
+ <label>Base URL<input name="base_url" required maxlength="2048" value="${baseUrl}" placeholder="https://api.anthropic.com"></label>
2495
+ <label>Model<input name="model" required maxlength="200" value="${model}" placeholder="your-model"${models.length > 0 ? html ` list="assistant-models"` : html ``}></label>${models.length > 0
2496
+ ? html `<datalist id="assistant-models">${models.map((id) => html `<option value="${id}"></option>`)}</datalist>`
2497
+ : html ``}
1633
2498
  <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>
1634
2499
  </div>
1635
2500
  <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>
1636
2501
  ${settings?.keyHint ? html `<label class="choice"><input type="checkbox" name="clear_key" value="1"> Remove the key</label>` : html ``}
1637
- <button class="primary" type="submit">Save</button>
2502
+ <div class="row-actions"><button class="primary" type="submit">Save</button><button type="submit" formaction="${root}/settings/assistant/models">Load models</button></div>
1638
2503
  </form>
1639
2504
  ${sentTo}
1640
2505
  ${settings
@@ -1662,6 +2527,8 @@ ${settings
1662
2527
  : 'Give an https base URL (http only for localhost or 127.0.0.1), with no user name, query or #fragment.');
1663
2528
  }
1664
2529
  const baseUrl = checkedUrl.url;
2530
+ if (namesOwnHost(baseUrl))
2531
+ return settingsPage(session, 400, OWN_HOST_REFUSAL);
1665
2532
  if (!(await checkSlotlockAssistantHost(baseUrl, { privateUrls, resolve: resolveHost }))) {
1666
2533
  return settingsPage(session, 400, PRIVATE_URL_REFUSAL);
1667
2534
  }
@@ -1685,6 +2552,71 @@ ${settings
1685
2552
  }
1686
2553
  return redirect(303, `${root}/settings#assistant`);
1687
2554
  }
2555
+ /**
2556
+ * The provider's models for the Model field (Load models): the form's provider and base URL,
2557
+ * checked as saving checks them, with the key given, or the saved one when it is for that same
2558
+ * provider and URL. Saves nothing; counted as a message to the assistant.
2559
+ */
2560
+ async function loadAssistantModels(session, form) {
2561
+ const store = assistantStore();
2562
+ if (!store || !assistantOptions)
2563
+ return notFound(session);
2564
+ if (!isAdmin(session))
2565
+ return adminOnly(session);
2566
+ if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
2567
+ return tooManyMessages(session, settingsPage);
2568
+ }
2569
+ const provider = form.get('provider');
2570
+ if (provider !== 'anthropic' && provider !== 'openai') {
2571
+ return settingsPage(session, 400, 'Choose a provider from the list.');
2572
+ }
2573
+ const typedModel = (form.get('model') ?? '').trim();
2574
+ const draft = {
2575
+ provider,
2576
+ baseUrl: (form.get('base_url') ?? '').trim(),
2577
+ ...(typedModel !== '' ? { model: typedModel } : {}),
2578
+ };
2579
+ const checkedUrl = readSlotlockAssistantBaseUrl(draft.baseUrl, { privateUrls });
2580
+ if (!checkedUrl.ok) {
2581
+ return settingsPage(session, 400, checkedUrl.reason === 'private'
2582
+ ? PRIVATE_URL_REFUSAL
2583
+ : 'Give an https base URL (http only for localhost or 127.0.0.1), with no user name, query or #fragment.', draft);
2584
+ }
2585
+ const baseUrl = checkedUrl.url;
2586
+ if (namesOwnHost(baseUrl))
2587
+ return settingsPage(session, 400, OWN_HOST_REFUSAL, draft);
2588
+ if (!(await checkSlotlockAssistantHost(baseUrl, { privateUrls, resolve: resolveHost }))) {
2589
+ return settingsPage(session, 400, PRIVATE_URL_REFUSAL, draft);
2590
+ }
2591
+ const typedKey = (form.get('api_key') ?? '').trim();
2592
+ if (typedKey !== '' && !SLOTLOCK_ASSISTANT_API_KEY.test(typedKey)) {
2593
+ return settingsPage(session, 400, 'An API key is up to 4,096 characters without spaces.', draft);
2594
+ }
2595
+ // The saved key only for the provider and URL it was entered for, as a model call sends it.
2596
+ let apiKey = typedKey !== '' ? typedKey : null;
2597
+ if (apiKey === null) {
2598
+ const found = await assistantModel(session);
2599
+ if (found && !('problem' in found) && found.model.provider === provider && found.model.baseUrl === baseUrl) {
2600
+ apiKey = found.model.apiKey;
2601
+ }
2602
+ }
2603
+ try {
2604
+ const listed = await listSlotlockAssistantModels({
2605
+ provider,
2606
+ baseUrl,
2607
+ apiKey,
2608
+ fetch: assistantFetch,
2609
+ timeoutMs: assistantTimeout,
2610
+ });
2611
+ return settingsPage(session, 200, listed.length > 0
2612
+ ? `${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.' : ''}`
2613
+ : 'The provider listed no models. Type the model name.', { ...draft, baseUrl, models: listed });
2614
+ }
2615
+ catch (error) {
2616
+ report(error instanceof Error ? error.message : 'assistant model list failed');
2617
+ return settingsPage(session, 502, slotlockAssistantProviderMessage(error), { ...draft, baseUrl });
2618
+ }
2619
+ }
1688
2620
  async function removeAssistantSettings(session) {
1689
2621
  const store = assistantStore();
1690
2622
  if (!store)
@@ -1741,6 +2673,12 @@ ${settings
1741
2673
  return assistantPage(session, 409, found.problem);
1742
2674
  const tenantRef = tenantOf(session);
1743
2675
  const member = personOf(session);
2676
+ try {
2677
+ ignoreRejection(assistantOptions?.onMessage?.(tenantRef));
2678
+ }
2679
+ catch {
2680
+ // Metering never changes an answer.
2681
+ }
1744
2682
  const stored = await store.getConversation(tenantRef, member);
1745
2683
  const entries = boundSlotlockAssistantEntries([
1746
2684
  ...readSlotlockAssistantEntries(stored?.messages),
@@ -1869,6 +2807,187 @@ ${settings
1869
2807
  await store.deleteConversation(tenantOf(session), personOf(session));
1870
2808
  return redirect(303, `${root}/assistant`);
1871
2809
  }
2810
+ async function planPage(session, status = 200, notice, thanks = false) {
2811
+ const billing = options.billing;
2812
+ if (!billing)
2813
+ return notFound(session);
2814
+ if (session.env === 'test') {
2815
+ // Its own use, counted apart from Live's and never billed.
2816
+ const range = slotlockUsageMonthToDate(now());
2817
+ const tested = options.usage && options.resources.usage
2818
+ ? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
2819
+ : null;
2820
+ const stat = (label, value) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${value.toLocaleString('en-GB')}</span></div>`;
2821
+ return respond(status, layout('Plan', html `${pageHead('Plan')}${noticeOf(notice)}<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
2822
+ ? html `<section class="panel" id="usage"><h2>Test usage this month</h2><div class="stats">${stat('Operations', tested.totals.operations)}${stat('Bookings', tested.totals.bookings)}${stat('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
2823
+ : html ``}`, session, 'plan'));
2824
+ }
2825
+ const tenantRef = tenantOf(session);
2826
+ const [plan, resourceCount, keys, webhooks] = await Promise.all([
2827
+ billing.plan(tenantRef),
2828
+ // As the limit counts them: archived resources included.
2829
+ options.resources.count
2830
+ ? options.resources.count(tenantRef)
2831
+ : options.resources.list(tenantRef).then((listed) => listed.length),
2832
+ options.keys.list({ tenantRef }),
2833
+ options.resources.webhooks?.list(tenantRef),
2834
+ ]);
2835
+ const month = slotlockUsageMonthToDate(now());
2836
+ const used = options.usage && options.resources.usage
2837
+ ? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
2838
+ : null;
2839
+ const keyName = new Map(keys.map((key) => [key.id, key.name]));
2840
+ const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
2841
+ const token = csrfToken(session);
2842
+ const paying = plan !== null && plan.plan !== 'free';
2843
+ const name = plan ? PLAN_NAMES[plan.plan] : 'No plan';
2844
+ // An organization is offered Team where the deployment sells it; a person, Developer.
2845
+ const offered = session.org !== undefined && billing.teamPrices ? 'team' : 'developer';
2846
+ const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
2847
+ const counted = (value) => value.toLocaleString('en-GB');
2848
+ const use = (label, used, limit) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${counted(used)}${limit === undefined ? html `` : html `<span class="meta"> of ${counted(limit)}</span>`}</span></div>`;
2849
+ // This month's quotas, when the deployment holds tenants to them.
2850
+ const quotas = options.quotas && plan ? SLOTLOCK_PLAN_QUOTAS[plan.plan] : null;
2851
+ const resets = formatDate(slotlockQuotaPeriod(now()).resetsAt);
2852
+ const quotaWarnings = quotas && used
2853
+ ? ['operations', 'bookings'].flatMap((metric) => {
2854
+ const quota = quotas[metric];
2855
+ const stop = Math.floor((quota * quotas.stopPercent) / 100);
2856
+ const spent = used.totals[metric];
2857
+ const what = metric === 'operations' ? 'operations' : 'bookings';
2858
+ const refused = metric === 'operations' ? 'every call but reading usage' : 'new bookings and holds';
2859
+ if (spent >= stop) {
2860
+ return [html `<p class="notice warn" role="alert">This month's ${what} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${paying ? html `` : html ` Upgrade for more.`}</p>`];
2861
+ }
2862
+ if (spent >= quota) {
2863
+ const billed = metric === 'operations' && billing.overage && plan?.plan === 'developer' && paying;
2864
+ return [html `<p class="notice warn" role="alert">You have used this month's ${counted(quota)} ${what}.${billed ? ` Those past it are billed at ${billing.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.</p>`];
2865
+ }
2866
+ if (spent * 100 >= quota * 80) {
2867
+ return [html `<p class="notice warn" role="status">You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${what}. They start again on ${resets}.</p>`];
2868
+ }
2869
+ return [];
2870
+ })
2871
+ : [];
2872
+ const warning = html `${plan?.status === 'past_due'
2873
+ ? html `<p class="notice warn" role="alert">Your last payment failed. Update your payment method to keep the ${name} plan.</p>`
2874
+ : html ``}${quotaWarnings}`;
2875
+ const free = SLOTLOCK_PLAN_LIMITS.free;
2876
+ const developer = SLOTLOCK_PLAN_LIMITS[offered];
2877
+ const upgrade = paying || !isAdmin(session)
2878
+ ? html ``
2879
+ : html `<section class="panel" id="upgrade">
2880
+ <h2>${PLAN_NAMES[offered]}</h2>
2881
+ <p>${String(developer.resources)} resources, ${String(developer.apiKeys)} API keys and ${String(developer.webhookEndpoints)} webhook endpoints${options.quotas ? 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' : ''}` : html ``}.${offered === 'developer' && billing.overage && options.quotas
2882
+ ? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
2883
+ : ''}</p>
2884
+ <form method="post" action="${root}/plan/upgrade">
2885
+ <input type="hidden" name="csrf" value="${token}">
2886
+ <input type="hidden" name="once" value="${formNonce(session)}">
2887
+ <fieldset class="modes"><legend>Billed</legend>
2888
+ <label class="choice"><input type="radio" name="interval" value="month" checked> Monthly, ${offeredPrices.month} a month</label>
2889
+ <label class="choice"><input type="radio" name="interval" value="year"> Yearly, ${offeredPrices.year} a year</label>
2890
+ </fieldset>
2891
+ <button class="primary" type="submit">Upgrade to ${PLAN_NAMES[offered]}</button>
2892
+ </form>
2893
+ <p class="fine">Paid through Stripe, in your currency, with tax added where it applies. Cancel at any time.</p>
2894
+ </section>`;
2895
+ const manage = plan?.stripeCustomer && isAdmin(session)
2896
+ ? 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>`
2897
+ : html ``;
2898
+ const renews = paying && plan?.currentPeriodEnd
2899
+ ? html `<p class="fine">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}.</p>`
2900
+ : html ``;
2901
+ const usageSection = used
2902
+ ? html `<section class="panel" id="usage">
2903
+ <h2>Usage this month</h2>
2904
+ <div class="stats">
2905
+ ${use('Operations', used.totals.operations, quotas?.operations)}
2906
+ ${use('Bookings', used.totals.bookings, quotas?.bookings)}
2907
+ ${use('Holds', used.totals.holds, undefined)}
2908
+ ${use('Webhook deliveries', used.totals.webhook_deliveries, undefined)}
2909
+ ${use('Assistant messages', used.totals.assistant_messages, undefined)}
2910
+ </div>
2911
+ <p class="fine">Since ${month.from}, in UTC. An operation is a call that reached Slotlock, whatever it answered; it shows here within 15 seconds. A booking is a reservation, a calendar event or a confirmed hold, and with holds shows within the hour.</p>
2912
+ ${used.keys.length === 0
2913
+ ? html ``
2914
+ : html `<div class="table"><table><thead><tr><th>Used by</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Used by">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${String(row.operations)}</td><td data-label="Bookings">${String(row.bookings)}</td><td data-label="Holds">${String(row.holds)}</td></tr>`)}</tbody></table></div>`}
2915
+ </section>`
2916
+ : html ``;
2917
+ const content = html `${thanks ? html `<p class="notice">${notice ?? ''}</p>` : noticeOf(notice)}
2918
+ ${warning}
2919
+ ${pageHead(`Plan: ${name}`, manage)}
2920
+ <div class="stats">
2921
+ ${use('Resources', resourceCount, plan?.limits.resources)}
2922
+ ${use('Active API keys', active.length, plan?.limits.apiKeys)}
2923
+ ${webhooks ? use('Webhooks', webhooks.length, plan?.limits.webhookEndpoints) : html ``}
2924
+ </div>
2925
+ ${renews}
2926
+ ${usageSection}
2927
+ ${plan ? html `` : html `<p class="fine">Free allows ${String(free.resources)} resources, ${String(free.apiKeys)} API keys and ${String(free.webhookEndpoints)} webhook endpoints${options.quotas ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS.free.operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS.free.bookings)} bookings a month` : html ``}.</p>`}
2928
+ ${upgrade}
2929
+ <section class="panel" id="custom">
2930
+ <h2>Custom</h2>
2931
+ <p>Slotlock on your own servers, with support from the people who build it.</p>
2932
+ <a class="button" href="${billing.contact}">Talk to the founder</a>
2933
+ </section>`;
2934
+ return respond(status, layout('Plan', content, session, 'plan'));
2935
+ }
2936
+ async function upgradePlan(session, form) {
2937
+ const billing = options.billing;
2938
+ if (!billing)
2939
+ return notFound(session);
2940
+ if (session.env === 'test')
2941
+ return planPage(session, 400, 'Plans are changed in Live.');
2942
+ if (!isAdmin(session))
2943
+ return planPage(session, 403, 'Only owners of the organization can change its plan.');
2944
+ const interval = form.get('interval') === 'year' ? 'year' : 'month';
2945
+ const tenantRef = tenantOf(session);
2946
+ const plan = await billing.plan(tenantRef);
2947
+ if (plan && plan.plan !== 'free') {
2948
+ return planPage(session, 409, `You are on the ${PLAN_NAMES[plan.plan]} plan already.`);
2949
+ }
2950
+ let url;
2951
+ try {
2952
+ url = await billing.checkout({
2953
+ tenantRef,
2954
+ interval,
2955
+ ...(session.org !== undefined && billing.teamPrices ? { plan: 'team' } : {}),
2956
+ customer: plan?.stripeCustomer ?? null,
2957
+ successUrl: `${publicRoot}/plan?checkout=done`,
2958
+ cancelUrl: `${publicRoot}/plan`,
2959
+ });
2960
+ }
2961
+ catch (error) {
2962
+ if (error?.code === 'subscription_exists') {
2963
+ return planPage(session, 409, 'You pay for a subscription already. Its plan shows here once Stripe confirms it; to change it, use Manage billing.');
2964
+ }
2965
+ ignoreRejection(options.onError?.(error));
2966
+ return planPage(session, 502, 'The payment page did not open. Try again in a minute.');
2967
+ }
2968
+ return redirect(303, url);
2969
+ }
2970
+ async function manageBilling(session) {
2971
+ const billing = options.billing;
2972
+ if (!billing)
2973
+ return notFound(session);
2974
+ if (session.env === 'test')
2975
+ return planPage(session, 400, 'Billing is managed in Live.');
2976
+ if (!isAdmin(session))
2977
+ return planPage(session, 403, 'Only owners of the organization can manage its billing.');
2978
+ const plan = await billing.plan(tenantOf(session));
2979
+ if (!plan?.stripeCustomer)
2980
+ return planPage(session, 409, 'There is no billing to manage yet.');
2981
+ let url;
2982
+ try {
2983
+ url = await billing.portal({ customer: plan.stripeCustomer, returnUrl: `${publicRoot}/plan` });
2984
+ }
2985
+ catch (error) {
2986
+ ignoreRejection(options.onError?.(error));
2987
+ return planPage(session, 502, 'Billing did not open. Try again in a minute.');
2988
+ }
2989
+ return redirect(303, url);
2990
+ }
1872
2991
  /** The page a form was sent from, to show what went wrong with it. */
1873
2992
  function pageFor(route) {
1874
2993
  if (route.startsWith('/keys'))
@@ -1877,12 +2996,21 @@ ${settings
1877
2996
  return webhooksPage;
1878
2997
  if (route.startsWith('/waitlist') && options.resources.waitlist)
1879
2998
  return waitlistPage;
2999
+ if (route.startsWith('/approvals') && options.resources.approvals)
3000
+ return approvalsPage;
3001
+ if ((route.startsWith('/locations') || route.startsWith('/groups')) && options.resources.grouping) {
3002
+ return locationsPage;
3003
+ }
1880
3004
  if (route.startsWith('/resources') || route === '/hours')
1881
3005
  return resourcesPage;
1882
3006
  if (route.startsWith('/assistant') && assistantStore())
1883
3007
  return assistantPage;
1884
3008
  if (route.startsWith('/settings'))
1885
3009
  return settingsPage;
3010
+ if (route.startsWith('/plan') && options.billing)
3011
+ return planPage;
3012
+ if (route.startsWith('/teams') && teams)
3013
+ return teamsPage;
1886
3014
  return overviewPage;
1887
3015
  }
1888
3016
  function hoursPage(session, state) {
@@ -1937,6 +3065,7 @@ ${state.unshown
1937
3065
  return resources.find((resource) => resource.id === id) ?? null;
1938
3066
  }
1939
3067
  const notFound = (session) => respond(404, layout('Not found', html `<p class="notice">That resource is not one of yours.</p>`, session));
3068
+ const nothingHere = (session) => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
1940
3069
  async function hoursEditor(session, url) {
1941
3070
  const id = url.searchParams.get('resource');
1942
3071
  if (id === null) {
@@ -2024,8 +3153,83 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2024
3153
  return respond(200, layout('Your new key', content, session, 'keys'));
2025
3154
  }
2026
3155
  /** Act in another of the session's organizations: the same session, sealed again. */
2027
- function switchOrganization(session, form) {
3156
+ function switchEnvironment(session, form) {
3157
+ const wanted = form.get('environment');
3158
+ if (wanted !== 'live' && wanted !== 'test') {
3159
+ return respond(400, layout('Refused', html `<p class="notice">Choose Live or Test.</p>`, session));
3160
+ }
3161
+ const { env: _, ...rest } = session;
3162
+ const sealed = seal('slotlock-dashboard-session-v1', wanted === 'test' ? { ...rest, env: 'test' } : rest);
3163
+ const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
3164
+ return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
3165
+ }
3166
+ /**
3167
+ * Teams on Cloud: the person's GitHub organizations, as read at sign-in, each on or off. An owner
3168
+ * turns one on or off; a member asks an owner; one that is on is a workspace to switch to.
3169
+ */
3170
+ async function teamsPage(session, status = 200, notice) {
3171
+ if (!teams)
3172
+ return nothingHere(session);
3173
+ const orgs = session.orgs ?? [];
3174
+ const enabled = orgs.length > 0 ? await teams.enabled(orgs.map((org) => org.id)) : new Set();
3175
+ enabledOf.set(session, enabled);
3176
+ const token = csrfToken(session);
3177
+ 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}">`;
3178
+ const rows = orgs.map((org) => {
3179
+ const on = enabled.has(org.id);
3180
+ const owner = org.role === 'admin';
3181
+ const actions = on
3182
+ ? 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
3183
+ ? 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>
3184
+ <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>`
3185
+ : html ``}`
3186
+ : owner
3187
+ ? html `<form class="inline" method="post" action="${root}/teams/enable">${hidden(org)}<button class="primary" type="submit">Turn on</button></form>`
3188
+ : html `<span class="muted">An owner can turn it on</span>`;
3189
+ 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>`;
3190
+ });
3191
+ const content = html `${noticeOf(notice)}${pageHead('Teams')}
3192
+ <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>
3193
+ ${rows.length === 0
3194
+ ? 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.')
3195
+ : 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>
3196
+ <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>`}`;
3197
+ return respond(status, layout('Teams', content, session, 'teams'));
3198
+ }
3199
+ async function changeTeam(session, form, on) {
3200
+ if (!teams)
3201
+ return nothingHere(session);
3202
+ const org = session.orgs?.find((candidate) => candidate.id === form.get('organization'));
3203
+ if (!org)
3204
+ return teamsPage(session, 400, 'That is not one of your organizations.');
3205
+ if (org.role !== 'admin')
3206
+ return teamsPage(session, 403, `Only an owner of ${org.login} can turn Slotlock ${on ? 'on' : 'off'} for it.`);
3207
+ // Who owns it was read as the person signed in: only a recent sign-in is trusted to say so.
3208
+ if (now() - (session.exp - SESSION_TTL_MS) > FRESH_SIGN_IN_MS) {
3209
+ 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.`);
3210
+ }
3211
+ if (on) {
3212
+ await teams.enable({ id: org.id, login: org.login }, personOf(session));
3213
+ return teamsPage(session, 200, `Slotlock is on for ${org.login}. Its members pick it from the workspace menu.`);
3214
+ }
3215
+ if (form.get('confirm') !== org.login)
3216
+ return teamsPage(session, 400, `Type ${org.login} to turn it off.`);
3217
+ await teams.disable(org.id, personOf(session));
3218
+ 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.`);
3219
+ }
3220
+ async function switchOrganization(session, form) {
2028
3221
  const wanted = form.get('organization') ?? '';
3222
+ if (teams) {
3223
+ // Their own tenant, or an organization of theirs that is turned on now.
3224
+ const org = session.orgs?.find((candidate) => candidate.id === wanted);
3225
+ if (wanted !== '' && (!org || !(await teams.enabled([wanted])).has(wanted))) {
3226
+ return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization. An owner turns it on from Teams.</p>`, session));
3227
+ }
3228
+ const { org: _, ...personal } = session;
3229
+ const sealed = seal('slotlock-dashboard-session-v1', wanted === '' ? personal : { ...personal, org: wanted });
3230
+ const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
3231
+ return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
3232
+ }
2029
3233
  const org = session.orgs?.find((candidate) => candidate.id === wanted && served?.has(candidate.id));
2030
3234
  if (!served || !org) {
2031
3235
  return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization.</p>`, session));
@@ -2046,7 +3250,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2046
3250
  code_challenge: challenge,
2047
3251
  code_challenge_method: 'S256',
2048
3252
  // Reading organization membership needs read:org; a personal tenant needs no scope at all.
2049
- ...(served ? { scope: 'read:org' } : {}),
3253
+ ...(readsOrganizations ? { scope: 'read:org' } : {}),
2050
3254
  }).toString();
2051
3255
  const sealed = seal('slotlock-dashboard-sign-in-v1', {
2052
3256
  state,
@@ -2152,7 +3356,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2152
3356
  continue;
2153
3357
  }
2154
3358
  const id = String(organization.id);
2155
- if (served?.has(id)) {
3359
+ if (teams || served?.has(id)) {
2156
3360
  found.set(id, { id, login: organization.login, role: membership.role === 'admin' ? 'admin' : 'member' });
2157
3361
  }
2158
3362
  }
@@ -2160,13 +3364,146 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2160
3364
  .sort((a, b) => a.login.localeCompare(b.login))
2161
3365
  .slice(0, MAX_SESSION_ORGANIZATIONS);
2162
3366
  }
3367
+ /** Google's sign-in (OpenID Connect, code flow with PKCE and a nonce), back to the same callback. */
3368
+ async function startGoogleSignIn(provider) {
3369
+ const state = randomBytes(32).toString('base64url');
3370
+ const verifier = randomBytes(32).toString('base64url');
3371
+ const nonce = randomBytes(24).toString('base64url');
3372
+ const target = new URL(provider.authorizeUrl ?? 'https://accounts.google.com/o/oauth2/v2/auth');
3373
+ target.search = new URLSearchParams({
3374
+ client_id: provider.clientId,
3375
+ redirect_uri: callbackUrl,
3376
+ response_type: 'code',
3377
+ scope: 'openid email',
3378
+ state,
3379
+ nonce,
3380
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'),
3381
+ code_challenge_method: 'S256',
3382
+ }).toString();
3383
+ const sealed = seal('slotlock-dashboard-sign-in-v1', {
3384
+ state,
3385
+ verifier,
3386
+ nonce,
3387
+ idp: 'google',
3388
+ exp: now() + SIGN_IN_TTL_MS,
3389
+ });
3390
+ return redirect(302, target.href, [setCookie(SIGN_IN_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
3391
+ }
3392
+ /** Google's signing keys by key id, kept for as long as its answer says (an hour by default). */
3393
+ let googleKeys = null;
3394
+ async function readGoogleKeys(provider, refresh) {
3395
+ if (googleKeys && !refresh && googleKeys.until > now())
3396
+ return googleKeys.keys;
3397
+ const response = await fetchImpl(provider.jwksUrl ?? 'https://www.googleapis.com/oauth2/v3/certs', {
3398
+ headers: { Accept: 'application/json' },
3399
+ redirect: 'manual',
3400
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
3401
+ });
3402
+ if (!response.ok)
3403
+ throw new Error(`Google keys answered HTTP ${response.status}`);
3404
+ const body = await response.json();
3405
+ const keys = new Map();
3406
+ for (const jwk of isRecord(body) && Array.isArray(body.keys) ? body.keys : []) {
3407
+ if (!isRecord(jwk) || jwk.kty !== 'RSA' || typeof jwk.kid !== 'string' || (jwk.alg !== undefined && jwk.alg !== 'RS256'))
3408
+ continue;
3409
+ const key = await globalThis.crypto.subtle
3410
+ .importKey('jwk', { kty: 'RSA', n: jwk.n, e: jwk.e, alg: 'RS256', ext: true }, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'])
3411
+ .catch(() => null);
3412
+ if (key)
3413
+ keys.set(jwk.kid, key);
3414
+ }
3415
+ const maxAge = Number(/max-age=(\d+)/.exec(response.headers.get('cache-control') ?? '')?.[1]);
3416
+ googleKeys = { keys, until: now() + (Number.isFinite(maxAge) && maxAge > 0 ? Math.min(maxAge * 1000, 86_400_000) : GOOGLE_KEYS_DEFAULT_TTL_MS) };
3417
+ return keys;
3418
+ }
3419
+ /** 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). */
3420
+ async function googleSigned(provider, header, payload, signature) {
3421
+ let parsed;
3422
+ try {
3423
+ parsed = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
3424
+ }
3425
+ catch {
3426
+ return false;
3427
+ }
3428
+ if (!isRecord(parsed) || parsed.alg !== 'RS256' || typeof parsed.kid !== 'string')
3429
+ return false;
3430
+ let key = (await readGoogleKeys(provider, false)).get(parsed.kid);
3431
+ if (!key)
3432
+ key = (await readGoogleKeys(provider, true)).get(parsed.kid);
3433
+ if (!key)
3434
+ return false;
3435
+ return globalThis.crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, Buffer.from(signature, 'base64url'), Buffer.from(`${header}.${payload}`, 'ascii'));
3436
+ }
3437
+ /**
3438
+ * The person Google signed in, from the ID token its token endpoint returned for this code. The
3439
+ * token's signature is checked against Google's published keys, and its issuer, audience (and
3440
+ * azp), expiry and nonce (OpenID Connect Core 1.0, 3.1.3.7).
3441
+ */
3442
+ async function askGoogle(provider, code, verifier, nonce) {
3443
+ try {
3444
+ const response = await fetchImpl(provider.tokenUrl ?? 'https://oauth2.googleapis.com/token', {
3445
+ method: 'POST',
3446
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
3447
+ body: new URLSearchParams({
3448
+ code,
3449
+ client_id: provider.clientId,
3450
+ client_secret: provider.clientSecret,
3451
+ redirect_uri: callbackUrl,
3452
+ grant_type: 'authorization_code',
3453
+ code_verifier: verifier,
3454
+ }).toString(),
3455
+ redirect: 'manual',
3456
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
3457
+ });
3458
+ if (!response.ok) {
3459
+ report(`Google token exchange failed: HTTP ${response.status}`);
3460
+ return null;
3461
+ }
3462
+ const body = await response.json().catch(() => null);
3463
+ const token = isRecord(body) && typeof body.id_token === 'string' ? body.id_token : null;
3464
+ const [header, payload, signature, ...extra] = token?.split('.') ?? [];
3465
+ if (!header || !payload || !signature || extra.length > 0 || ![header, payload, signature].every((part) => BASE64URL.test(part))) {
3466
+ return null;
3467
+ }
3468
+ // Signed by Google, with one of the keys it publishes (RS256), whichever way the token came.
3469
+ if (!(await googleSigned(provider, header, payload, signature))) {
3470
+ report('Google sign-in refused: the ID token is not signed by Google');
3471
+ return null;
3472
+ }
3473
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
3474
+ if (!isRecord(claims))
3475
+ return null;
3476
+ const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
3477
+ if ((claims.iss !== 'https://accounts.google.com' && claims.iss !== 'accounts.google.com') ||
3478
+ !audience.includes(provider.clientId) ||
3479
+ (audience.length > 1 && claims.azp !== provider.clientId) ||
3480
+ typeof claims.exp !== 'number' ||
3481
+ claims.exp * 1000 <= now() ||
3482
+ typeof claims.nonce !== 'string' ||
3483
+ !safeEqual(claims.nonce, nonce) ||
3484
+ typeof claims.sub !== 'string' ||
3485
+ !GOOGLE_SUBJECT.test(claims.sub)) {
3486
+ report('Google sign-in refused: the ID token did not check out');
3487
+ return null;
3488
+ }
3489
+ // The email names the person on the page only, and only once Google has verified it.
3490
+ const email = claims.email_verified === true && typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email)
3491
+ ? claims.email
3492
+ : 'Google account';
3493
+ return { uid: claims.sub, login: email, idp: 'google' };
3494
+ }
3495
+ catch (error) {
3496
+ report(`Google sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`);
3497
+ return null;
3498
+ }
3499
+ }
2163
3500
  async function askGitHub(code, verifier) {
2164
3501
  try {
2165
3502
  const token = await exchangeCode(code, verifier);
2166
3503
  if (token === null)
2167
3504
  return null;
2168
3505
  const user = await readGitHubUser(token);
2169
- if (!user || !served)
3506
+ if (!user || !readsOrganizations)
2170
3507
  return user;
2171
3508
  const orgs = await readMemberships(token);
2172
3509
  return orgs === null ? null : { ...user, orgs };
@@ -2208,7 +3545,12 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2208
3545
  if (!(await options.state.use('sign_in', pending.state, pending.exp))) {
2209
3546
  return signedOutPage(400, SIGN_IN_AGAIN, [clearSignIn]);
2210
3547
  }
2211
- user = await askGitHub(code, verifier);
3548
+ user =
3549
+ pending.idp === 'google'
3550
+ ? google && typeof pending.nonce === 'string'
3551
+ ? await askGoogle(google, code, verifier, pending.nonce)
3552
+ : null
3553
+ : await askGitHub(code, verifier);
2212
3554
  }
2213
3555
  finally {
2214
3556
  signInsInFlight -= 1;
@@ -2221,12 +3563,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2221
3563
  }
2222
3564
  }
2223
3565
  if (!user) {
2224
- return signedOutPage(502, 'GitHub did not complete the sign-in. Try again.', [clearSignIn]);
3566
+ return signedOutPage(502, `${pending.idp === 'google' ? 'Google' : 'GitHub'} did not complete the sign-in. Try again.`, [clearSignIn]);
2225
3567
  }
2226
- if (!allowed(user.uid)) {
2227
- return signedOutPage(403, `This Slotlock server does not let @${user.login} sign in.`, [
2228
- clearSignIn,
2229
- ]);
3568
+ if (!allowed(user.uid, user.idp)) {
3569
+ return signedOutPage(403, `This Slotlock server does not let ${user.idp === 'google' ? user.login : `@${user.login}`} sign in.`, [clearSignIn]);
2230
3570
  }
2231
3571
  const orgs = user.orgs ?? [];
2232
3572
  if (served && orgs.length === 0) {
@@ -2237,7 +3577,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2237
3577
  login: user.login,
2238
3578
  sid: randomBytes(24).toString('base64url'),
2239
3579
  exp: now() + SESSION_TTL_MS,
2240
- ...(served ? { orgs, org: orgs[0].id } : {}),
3580
+ ...(user.idp === 'google' ? { idp: 'google' } : {}),
3581
+ ...(served ? { orgs, org: orgs[0].id } : teams && user.idp !== 'google' ? { orgs } : {}),
2241
3582
  });
2242
3583
  return redirect(303, root, [
2243
3584
  clearSignIn,
@@ -2277,6 +3618,25 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2277
3618
  return keysPage(session, 400, 'Choose an access level and an expiry from the lists.');
2278
3619
  }
2279
3620
  const days = KEY_EXPIRY_DAYS[expiry];
3621
+ const limited = {
3622
+ locations: [...new Set(form.getAll('limit_location'))],
3623
+ groups: [...new Set(form.getAll('limit_group'))],
3624
+ };
3625
+ const resourceScope = limited.locations.length + limited.groups.length > 0 ? limited : undefined;
3626
+ const limits = keyLimitsOf(form);
3627
+ if (typeof limits === 'string')
3628
+ return keysPage(session, 400, limits);
3629
+ if (resourceScope) {
3630
+ if (scopes.includes('admin')) {
3631
+ return keysPage(session, 400, 'A key limited to locations or groups reads and writes only: choose that access.');
3632
+ }
3633
+ // Keys limited to part of the tenant come with Developer (RFC 0003 pricing); a server without
3634
+ // plans allows them.
3635
+ const plan = options.billing ? await options.billing.plan(tenantOf(session)) : null;
3636
+ if (plan?.plan === 'free') {
3637
+ return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Plan.');
3638
+ }
3639
+ }
2280
3640
  try {
2281
3641
  const created = await options.keys.create({
2282
3642
  tenantRef: tenantOf(session),
@@ -2284,6 +3644,8 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2284
3644
  scopes,
2285
3645
  createdBy: personOf(session),
2286
3646
  origin: 'dashboard',
3647
+ ...(resourceScope ? { resourceScope } : {}),
3648
+ ...(Object.keys(limits).length > 0 ? { limits } : {}),
2287
3649
  ...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
2288
3650
  });
2289
3651
  return shownOncePage(session, created.key, created.apiKey, false);
@@ -2293,8 +3655,13 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2293
3655
  if (code === 'invalid_api_key_name') {
2294
3656
  return keysPage(session, 400, 'Give the key a name of 1 to 100 characters.');
2295
3657
  }
3658
+ if (code === 'invalid_api_key_resource_scope') {
3659
+ return keysPage(session, 400, 'Choose up to 20 locations and 20 groups from the lists.');
3660
+ }
2296
3661
  if (code === 'api_key_limit_reached') {
2297
- return keysPage(session, 409, 'You have as many keys as Slotlock allows. Revoke one first.');
3662
+ return keysPage(session, 409, options.billing
3663
+ ? 'You have as many active keys as your plan allows. Revoke one first, or see Plan.'
3664
+ : 'You have as many keys as Slotlock allows. Revoke one first.');
2298
3665
  }
2299
3666
  throw error;
2300
3667
  }
@@ -2305,6 +3672,40 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2305
3672
  actor: personOf(session),
2306
3673
  origin: 'dashboard',
2307
3674
  });
3675
+ /** A key's limits from its form (RFC 0004 A5): each empty field the server's; else what is wrong. */
3676
+ function keyLimitsOf(form) {
3677
+ const whole = (name, most) => {
3678
+ const text = (form.get(name) ?? '').trim();
3679
+ if (text === '')
3680
+ return undefined;
3681
+ const value = /^[0-9]{1,7}$/.test(text) ? Number(text) : Number.NaN;
3682
+ return Number.isSafeInteger(value) && value >= 1 && value <= most ? value : null;
3683
+ };
3684
+ const maxHolds = whole('max_holds', 10_000);
3685
+ const maxHeldHours = whole('max_held_hours', 876_000);
3686
+ const requestsPerMinute = whole('requests_per_minute', 100_000);
3687
+ if (maxHolds === null || maxHeldHours === null || requestsPerMinute === null) {
3688
+ 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.';
3689
+ }
3690
+ return {
3691
+ ...(maxHolds !== undefined ? { maxHolds } : {}),
3692
+ ...(maxHeldHours !== undefined ? { maxHeldHours } : {}),
3693
+ ...(requestsPerMinute !== undefined ? { requestsPerMinute } : {}),
3694
+ ...(form.get('requires_approval') === 'on' ? { requiresApproval: true } : {}),
3695
+ };
3696
+ }
3697
+ async function saveKeyLimits(session, form) {
3698
+ const id = form.get('id') ?? '';
3699
+ if (!UUID_PATTERN.test(id))
3700
+ return keysPage(session, 404, 'That key is not one of yours.');
3701
+ const limits = keyLimitsOf(form);
3702
+ if (typeof limits === 'string')
3703
+ return keysPage(session, 400, limits);
3704
+ if (!(await options.keys.setLimits({ tenantRef: tenantOf(session), id, limits }))) {
3705
+ return keysPage(session, 404, 'That key is not an active key of yours.');
3706
+ }
3707
+ return redirect(303, `${root}/keys`);
3708
+ }
2308
3709
  async function changeKey(session, form, change) {
2309
3710
  const id = form.get('id') ?? '';
2310
3711
  if (!UUID_PATTERN.test(id))
@@ -2332,7 +3733,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2332
3733
  catch (error) {
2333
3734
  const code = error.code;
2334
3735
  if (code === 'resource_limit_reached') {
2335
- return resourcesPage(session, 409, `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
3736
+ const limit = error.limit;
3737
+ return resourcesPage(session, 409, typeof limit === 'number' && limit < SLOTLOCK_DASHBOARD_MAX_RESOURCES
3738
+ ? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Plan.`
3739
+ : `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
2336
3740
  }
2337
3741
  if (code === 'invalid_timezone') {
2338
3742
  return resourcesPage(session, 400, `${timezone} is not a time zone Slotlock knows, such as Europe/London.`);
@@ -2382,7 +3786,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2382
3786
  try {
2383
3787
  first =
2384
3788
  checked === 'ok' &&
2385
- (await options.state.use('form', once, session.exp, session.uid));
3789
+ (await options.state.use('form', once, session.exp, session.idp === 'google' ? personOf(session) : session.uid));
2386
3790
  }
2387
3791
  catch (error) {
2388
3792
  if (error.code !== 'dashboard_form_limit')
@@ -2393,6 +3797,10 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2393
3797
  return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
2394
3798
  }
2395
3799
  }
3800
+ // A tenant new to plans is put on the default plan before it adds what plans limit.
3801
+ if (route === '/keys' || route === '/resources' || route === '/webhooks') {
3802
+ await options.billing?.enrol?.(tenantOf(session));
3803
+ }
2396
3804
  switch (route) {
2397
3805
  case '/sign-out':
2398
3806
  await options.state.endSession(session.sid, session.exp);
@@ -2403,16 +3811,34 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2403
3811
  return changeKey(session, form, 'rotate');
2404
3812
  case '/keys/revoke':
2405
3813
  return changeKey(session, form, 'revoke');
3814
+ case '/keys/limits':
3815
+ return saveKeyLimits(session, form);
2406
3816
  case '/resources':
2407
3817
  return addResource(session, form);
2408
3818
  case '/hours':
2409
3819
  return saveHours(session, form);
2410
3820
  case '/organization':
2411
3821
  return switchOrganization(session, form);
3822
+ case '/environment':
3823
+ return switchEnvironment(session, form);
2412
3824
  case '/resources/rules':
2413
3825
  return saveRules(session, form);
2414
3826
  case '/resources/pool':
2415
3827
  return savePool(session, form);
3828
+ case '/resources/profile':
3829
+ return saveProfile(session, form);
3830
+ case '/resources/place':
3831
+ return savePlace(session, form);
3832
+ case '/resources/parent':
3833
+ return saveParent(session, form);
3834
+ case '/locations':
3835
+ return saveLocation(session, form);
3836
+ case '/locations/delete':
3837
+ return deleteGrouping(session, form, 'location');
3838
+ case '/groups':
3839
+ return saveGroup(session, form);
3840
+ case '/groups/delete':
3841
+ return deleteGrouping(session, form, 'group');
2416
3842
  case '/webhooks':
2417
3843
  return addWebhook(session, form);
2418
3844
  case '/webhooks/delete':
@@ -2421,6 +3847,14 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2421
3847
  return changeWebhook(session, form, 'enable');
2422
3848
  case '/waitlist/leave':
2423
3849
  return leaveWaitlist(session, form);
3850
+ case '/approvals/decide':
3851
+ return decideApproval(session, form);
3852
+ case '/sandbox/seed':
3853
+ return sandboxAction(session, form, 'seed');
3854
+ case '/sandbox/reset':
3855
+ return sandboxAction(session, form, 'reset');
3856
+ case '/sandbox/fast-forward':
3857
+ return sandboxAction(session, form, 'fast-forward');
2424
3858
  case '/assistant':
2425
3859
  return sendToAssistant(session, form);
2426
3860
  case '/assistant/approve':
@@ -2431,10 +3865,20 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2431
3865
  return resetAssistant(session);
2432
3866
  case '/settings/assistant':
2433
3867
  return saveAssistantSettings(session, form);
3868
+ case '/settings/assistant/models':
3869
+ return loadAssistantModels(session, form);
2434
3870
  case '/settings/assistant/test':
2435
3871
  return testAssistant(session);
2436
3872
  case '/settings/assistant/remove':
2437
3873
  return removeAssistantSettings(session);
3874
+ case '/plan/upgrade':
3875
+ return upgradePlan(session, form);
3876
+ case '/plan/manage':
3877
+ return manageBilling(session);
3878
+ case '/teams/enable':
3879
+ return changeTeam(session, form, true);
3880
+ case '/teams/disable':
3881
+ return changeTeam(session, form, false);
2438
3882
  default:
2439
3883
  return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
2440
3884
  }
@@ -2458,11 +3902,21 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2458
3902
  case '/resources':
2459
3903
  case '/webhooks':
2460
3904
  case '/waitlist':
3905
+ case '/approvals':
2461
3906
  case '/assistant':
3907
+ case '/plan':
3908
+ case '/teams':
2462
3909
  case '/settings': {
2463
3910
  const session = await readSession(request);
2464
3911
  if (!session)
2465
3912
  return redirect(303, root);
3913
+ if (route === '/teams')
3914
+ return await teamsPage(session);
3915
+ if (route === '/plan') {
3916
+ return await planPage(session, 200, url.searchParams.get('checkout') === 'done'
3917
+ ? 'Thank you. Your plan changes here once Stripe confirms the payment, usually within a minute.'
3918
+ : undefined, url.searchParams.get('checkout') === 'done');
3919
+ }
2466
3920
  if (route === '/keys')
2467
3921
  return await keysPage(session);
2468
3922
  if (route === '/resources')
@@ -2471,22 +3925,34 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
2471
3925
  return await webhooksPage(session);
2472
3926
  if (route === '/waitlist')
2473
3927
  return await waitlistPage(session);
3928
+ if (route === '/approvals')
3929
+ return await approvalsPage(session);
2474
3930
  if (route === '/assistant')
2475
3931
  return await assistantPage(session);
2476
- return await settingsPage(session);
3932
+ // A provider preset fills the assistant's form; nothing is saved until Save.
3933
+ const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
3934
+ return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
2477
3935
  }
2478
3936
  case '/style.css':
2479
3937
  return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
2480
3938
  case '/app.js':
2481
3939
  return asset(DASHBOARD_JS, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
3940
+ case '/favicon.svg':
3941
+ return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
2482
3942
  case '/sign-in':
2483
- return await startSignIn();
3943
+ return google && url.searchParams.get('with') === 'google'
3944
+ ? await startGoogleSignIn(google)
3945
+ : await startSignIn();
2484
3946
  case '/callback':
2485
3947
  return await finishSignIn(request, url);
2486
3948
  case '/hours': {
2487
3949
  const session = await readSession(request);
2488
3950
  return session ? await hoursEditor(session, url) : redirect(303, root);
2489
3951
  }
3952
+ case '/locations': {
3953
+ const session = await readSession(request);
3954
+ return session ? await locationsPage(session) : redirect(303, root);
3955
+ }
2490
3956
  default: {
2491
3957
  const resourceId = RESOURCE_PAGE.exec(route)?.[1];
2492
3958
  if (resourceId !== undefined) {
@@ -2631,6 +4097,12 @@ code, pre, kbd, input[readonly] { font-family: var(--mono); font-size: 12.5px; }
2631
4097
  .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; }
2632
4098
  .workspace select:focus { box-shadow: none; }
2633
4099
  .workspace:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
4100
+ .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); }
4101
+ .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; }
4102
+ .environment button[aria-pressed="true"] { background: var(--surface-2); color: var(--ink); box-shadow: 0 0 0 1px var(--line); }
4103
+ .environment button[aria-pressed="true"][value="test"] { background: var(--warn-wash); color: var(--warn); box-shadow: 0 0 0 1px var(--warn-line); }
4104
+ .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); }
4105
+ .test-banner code { background: none; padding: 0; }
2634
4106
  .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; }
2635
4107
  .nav { display: grid; gap: 1px; }
2636
4108
  .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); }
@@ -2731,9 +4203,13 @@ textarea { padding: 10px var(--space-3); resize: vertical; min-height: 72px; lin
2731
4203
  .create { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin: 0; }
2732
4204
  .create .grow { flex: 1 1 220px; }
2733
4205
  label > input, label > select, label > .unit-input { width: 100%; }
4206
+ label > textarea { width: 100%; padding: var(--space-2) var(--space-3); resize: vertical; }
4207
+ .presets { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin: 0 0 var(--space-3); }
4208
+ .presets button { height: 28px; padding: 0 var(--space-3); font-size: var(--text-sm); }
2734
4209
  label.choice > input { width: 16px; }
2735
4210
  .create label:not(.grow) { flex: 0 1 auto; }
2736
4211
  form > label { margin-bottom: var(--space-3); }
4212
+ .create > label { margin-bottom: 0; }
2737
4213
  .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; }
2738
4214
  .unit-input { display: flex; align-items: center; gap: var(--space-2); }
2739
4215
  .unit-input input { width: 100%; }
@@ -2748,6 +4224,12 @@ form .choice { margin: var(--space-1) 0 var(--space-3); }
2748
4224
  .types summary { cursor: pointer; color: var(--ink-2); font-size: var(--text-md); width: fit-content; }
2749
4225
  .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); }
2750
4226
  .checks .choice { font-size: var(--text-md); margin: 0; }
4227
+ .choices { border: 0; padding: 0; margin: var(--space-4) 0; min-width: 0; }
4228
+ .choices legend { font-size: var(--text-sm); font-weight: 500; color: var(--ink-2); padding: 0; }
4229
+ .choices .checks { margin-top: 6px; }
4230
+ .fine + .table, .fine + .empty { margin-top: var(--space-4); }
4231
+ .pop label { margin-bottom: var(--space-3); }
4232
+ .pop textarea { width: 100%; }
2751
4233
 
2752
4234
  /* Buttons */
2753
4235
  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); }
@@ -2878,6 +4360,7 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
2878
4360
  .side-top .brand .name { display: none; }
2879
4361
  .workspace { flex: 1; min-width: 0; padding: 3px 4px; }
2880
4362
  .workspace .avatar { display: none; }
4363
+ .environment { margin: 0; flex: none; }
2881
4364
  .me { grid-column: 2; grid-row: 1; margin: 0; padding: 0; border: 0; }
2882
4365
  .me-avatar, .me .login { display: none; }
2883
4366
  .sign-out { width: 36px; height: 36px; padding: 0; }
@@ -2916,6 +4399,7 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
2916
4399
  .week th[scope="row"] { display: block; padding: var(--space-2) var(--space-4) 0; }
2917
4400
  .actions { text-align: left; width: auto; padding-top: var(--space-2); }
2918
4401
  .act { margin: 0 var(--space-2) 0 0; }
4402
+ .actions form.inline { margin-right: var(--space-2); }
2919
4403
  .act:first-child, form.inline + .act { margin-left: 0; }
2920
4404
  .act summary { border: 1px solid var(--line-strong); height: 32px; }
2921
4405
  .act.danger summary { border-color: var(--bad-line); }
@@ -2934,7 +4418,17 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
2934
4418
  .pill, .chip, .nav a[aria-current="page"] { border: 1px solid CanvasText; }
2935
4419
  }
2936
4420
  `;
4421
+ /** A composite the store refused, in a person's words. */
4422
+ const COMPOSITE_PROBLEMS = Object.freeze({
4423
+ parent_not_found: 'That is not one of your resources.',
4424
+ composite_too_deep: 'A part has no parts of its own, and a whole is no part: choose a resource that stands alone.',
4425
+ composite_parts_limit: 'A whole has at most 50 parts.',
4426
+ composite_busy: 'One of them has a booking or hold still to come. A composite changes only while neither does.',
4427
+ });
4428
+ /** What the Plan page calls each plan. */
4429
+ const PLAN_NAMES = Object.freeze({ free: 'Free', developer: 'Developer', team: 'Team' });
2937
4430
  const ASSET_VERSIONS = Object.freeze({
2938
4431
  css: createHash('sha256').update(DASHBOARD_CSS).digest('base64url').slice(0, 12),
2939
4432
  js: createHash('sha256').update(DASHBOARD_JS).digest('base64url').slice(0, 12),
4433
+ icon: createHash('sha256').update(SLOTLOCK_DASHBOARD_ICON_SVG).digest('base64url').slice(0, 12),
2940
4434
  });