slotlock 0.9.0 → 0.10.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 (61) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-store-backend.js +209 -15
  5. package/application-api.d.ts +10 -1
  6. package/application-api.js +213 -2
  7. package/availability-store.js +13 -5
  8. package/booking-rules.d.ts +11 -0
  9. package/booking-rules.js +21 -1
  10. package/config.d.ts +50 -0
  11. package/config.js +107 -0
  12. package/dashboard-scope.d.ts +7 -0
  13. package/dashboard-scope.js +21 -0
  14. package/dashboard.d.ts +97 -2
  15. package/dashboard.js +3068 -284
  16. package/ddl-identity.d.ts +25 -0
  17. package/ddl-identity.js +881 -0
  18. package/ddl-scim.d.ts +5 -0
  19. package/ddl-scim.js +740 -0
  20. package/ddl-sso.d.ts +15 -0
  21. package/ddl-sso.js +1033 -0
  22. package/ddl.d.ts +40 -9
  23. package/ddl.js +2123 -49
  24. package/domains.d.ts +117 -0
  25. package/domains.js +340 -0
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +6 -34
  28. package/holiday-import.d.ts +1 -1
  29. package/holiday-import.js +2 -3
  30. package/holidays.js +70 -11
  31. package/index.d.ts +12 -4
  32. package/index.js +8 -2
  33. package/invitation-email.d.ts +26 -0
  34. package/invitation-email.js +73 -0
  35. package/maintenance.d.ts +7 -2
  36. package/maintenance.js +15 -1
  37. package/oidc.d.ts +135 -0
  38. package/oidc.js +452 -0
  39. package/openapi.json +1886 -6
  40. package/package.json +2 -1
  41. package/scim-server.d.ts +19 -0
  42. package/scim-server.js +243 -0
  43. package/scim.d.ts +180 -0
  44. package/scim.js +416 -0
  45. package/self-host.d.ts +19 -6
  46. package/self-host.js +262 -7
  47. package/slotlock.manifest.json +1 -1
  48. package/store.d.ts +273 -2
  49. package/store.js +984 -72
  50. package/types.d.ts +33 -1
  51. package/types.js +68 -1
  52. package/workspace-domains.d.ts +146 -0
  53. package/workspace-domains.js +249 -0
  54. package/workspace-roles.d.ts +47 -0
  55. package/workspace-roles.js +304 -0
  56. package/workspace-scim.d.ts +89 -0
  57. package/workspace-scim.js +157 -0
  58. package/workspace-sso.d.ts +131 -0
  59. package/workspace-sso.js +180 -0
  60. package/workspaces.d.ts +240 -0
  61. package/workspaces.js +433 -0
package/dashboard.js CHANGED
@@ -13,7 +13,13 @@
13
13
  // once) and signed-out sessions (a copied cookie stays signed out). `createSlotlockDashboardState`
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
- import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
16
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
17
+ import { SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES, SLOTLOCK_DASHBOARD_POST_CAPABILITIES, SLOTLOCK_FRESH_CAPABILITIES, SLOTLOCK_SCOPED_DASHBOARD_PAGES, SLOTLOCK_SCOPED_DASHBOARD_POSTS, SLOTLOCK_WORKSPACE_ROLE_NAMES, SLOTLOCK_WORKSPACE_ROLES, isSlotlockWorkspaceRole, slotlockMemberCan, slotlockRoleCan, slotlockRoleMayGrant, } from './workspace-roles.js';
18
+ import { slotlockDomainRecord, slotlockEmailDomain } from './domains.js';
19
+ import { SLOTLOCK_GOOGLE_ISSUERS, createSlotlockJwksCache, discoverSlotlockOidc, slotlockEntraIssuer, slotlockOidcUrlAllowed, slotlockReadJsonBody, verifySlotlockIdToken, } from './oidc.js';
20
+ import { handleSlotlockScimRequest } from './scim-server.js';
21
+ import { SLOTLOCK_DOMAIN_AUDIT_ACTIONS, slotlockDomainCheckEvent, slotlockJoinRole, } from './workspace-domains.js';
22
+ import { SLOTLOCK_WORKSPACE_DOMAIN_LIMIT } from './ddl-identity.js';
17
23
  import { SLOTLOCK_TEST_TENANT_SUFFIX, } from './api-keys.js';
18
24
  import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
19
25
  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, SLOTLOCK_ASSISTANT_DIRECTORY_SIZE, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
@@ -21,18 +27,20 @@ import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
21
27
  import { createSlotlockRateLimiter } from './rate-limit.js';
22
28
  import { createSlotlockGuardedFetch } from './webhooks.js';
23
29
  import { lookup as dnsLookup } from 'node:dns/promises';
24
- import { SLOTLOCK_ASSISTANT_CHAT_ID, SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
30
+ import { SLOTLOCK_ASSISTANT_CHAT_ID, SLOTLOCK_DASHBOARD_FORM_LIMIT, SLOTLOCK_INVITATIONS_PER_ADDRESS } from './ddl.js';
25
31
  import { ignoreRejection } from './hooks.js';
26
32
  import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
27
33
  import { SLOTLOCK_DASHBOARD_ICON_SVG } from './dashboard-icon.js';
34
+ import { runSlotlockDashboardRequest, setSlotlockDashboardRequestScope, slotlockDashboardRequestScope, } from './dashboard-scope.js';
28
35
  import { SLOTLOCK_SETTINGS_CSS } from './dashboard-settings-ui.js';
29
36
  import { renderSlotlockMarkdown } from './dashboard-markdown.js';
30
37
  import { SLOTLOCK_ASSISTANT_CSS, slotlockAssistantActionVerb, slotlockAssistantScript, slotlockAssistantStepLabel, } from './dashboard-assistant-ui.js';
31
38
  import { SLOTLOCK_PLAN_LIMITS, SLOTLOCK_PLAN_QUOTAS } from './plans.js';
32
- import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
39
+ import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, SLOTLOCK_MAX_KEY_SCOPE_REFS, isSlotlockResourceScope, } from './resource-profile.js';
33
40
  import { slotlockQuotaPeriod } from './quotas.js';
34
41
  import { slotlockUsageMonthToDate, summarizeSlotlockUsage } from './usage.js';
35
42
  import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
43
+ import { buildSlotlockTenantExport, } from './store.js';
36
44
  import { dayToDate } from './hours.js';
37
45
  import { SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES } from './holidays.js';
38
46
  import { readSlotlockHolidayCalendar } from './holiday-import.js';
@@ -73,6 +81,8 @@ const MAX_REMEMBERED = 10_000;
73
81
  const FORM_NONCE = /^([A-Za-z0-9_-]{24})\.([A-Za-z0-9_-]{43})$/;
74
82
  const POST_ROUTES = new Set([
75
83
  '/sign-out',
84
+ '/sign-out-everywhere',
85
+ '/sessions/end',
76
86
  '/keys',
77
87
  '/keys/rotate',
78
88
  '/keys/revoke',
@@ -105,6 +115,38 @@ const POST_ROUTES = new Set([
105
115
  '/environment',
106
116
  '/teams/enable',
107
117
  '/teams/disable',
118
+ '/teams/create',
119
+ '/teams/delete',
120
+ '/teams/restore',
121
+ '/invite/accept',
122
+ '/members/invite',
123
+ '/members/resend',
124
+ '/members/revoke',
125
+ '/members/role',
126
+ '/members/scope',
127
+ '/members/remove',
128
+ '/members/leave',
129
+ '/members/requests',
130
+ '/join/request',
131
+ '/identity/domains',
132
+ '/identity/domains/verify',
133
+ '/identity/domains/remove',
134
+ '/identity/domains/policy',
135
+ '/identity/invites',
136
+ '/identity/sso',
137
+ '/identity/sso/test',
138
+ '/identity/sso/status',
139
+ '/identity/sso/delete',
140
+ '/identity/sso/provisioning',
141
+ '/identity/sso/mapping',
142
+ '/identity/sso/require',
143
+ '/identity/scim/enable',
144
+ '/identity/scim/disable',
145
+ '/identity/scim/token',
146
+ '/identity/scim/token/revoke',
147
+ '/identity/scim/group',
148
+ '/sso/proof',
149
+ '/sso/bypass',
108
150
  '/assistant',
109
151
  '/assistant/stream',
110
152
  '/assistant/approve',
@@ -118,11 +160,87 @@ const POST_ROUTES = new Set([
118
160
  '/settings/assistant/remove',
119
161
  '/plan/upgrade',
120
162
  '/plan/manage',
163
+ '/settings/privacy/export',
164
+ '/settings/privacy/export/delete',
165
+ '/settings/privacy/person',
166
+ '/settings/privacy/person/export',
167
+ '/settings/privacy/person/erase',
168
+ ]);
169
+ /** The forms whose success the audit log records, by the action it records. */
170
+ const AUDITED_ROUTES = Object.freeze({
171
+ '/keys': 'api_key.created',
172
+ '/keys/rotate': 'api_key.rotated',
173
+ '/keys/revoke': 'api_key.revoked',
174
+ '/keys/limits': 'api_key.limits_changed',
175
+ '/webhooks': 'webhook.created',
176
+ '/webhooks/delete': 'webhook.deleted',
177
+ '/webhooks/enable': 'webhook.enabled',
178
+ '/contacts/forget': 'contact.forgotten',
179
+ '/settings/assistant': 'assistant.settings_saved',
180
+ '/settings/assistant/remove': 'assistant.settings_removed',
181
+ '/plan/upgrade': 'billing.checkout_started',
182
+ '/plan/manage': 'billing.portal_opened',
183
+ '/teams/enable': 'organization.enabled',
184
+ '/teams/disable': 'organization.disabled',
185
+ '/sandbox/reset': 'sandbox.reset',
186
+ '/sign-out-everywhere': 'session.signed_out_everywhere',
187
+ '/sessions/end': 'session.ended',
188
+ });
189
+ /** What each capability lets a person do, as a refusal says it. */
190
+ const CAPABILITY_WORDS = Object.freeze({
191
+ 'account.use': 'use this account',
192
+ 'workspace.read': 'see this workspace',
193
+ 'usage.read': 'see its usage',
194
+ 'assistant.chat': 'chat with the assistant',
195
+ 'operations.run': 'change bookings, hours or closures',
196
+ 'resources.manage': 'change resources',
197
+ 'sandbox.manage': 'change the test environment',
198
+ 'keys.manage_own': 'create or change API keys',
199
+ 'keys.full': 'create or change Full-access keys',
200
+ 'keys.manage_any': "change a key someone else created",
201
+ 'webhooks.manage': 'change webhooks',
202
+ 'contacts.forget': 'forget contacts',
203
+ 'assistant.settings': "change the assistant's model",
204
+ 'members.manage': 'manage members',
205
+ 'audit.read': 'read the audit log',
206
+ 'privacy.manage': "export the workspace's data or erase a person",
207
+ 'billing.manage': 'change the plan or billing',
208
+ 'workspace.own': 'change who owns this workspace',
209
+ 'identity.manage': "change the team's domains",
210
+ });
211
+ /** What a session signed in through a team's provider alone may not do: anything beyond its team. */
212
+ const SSO_SCOPED_REFUSED = new Set([
213
+ '/organization',
214
+ '/teams/create',
215
+ '/teams/enable',
216
+ '/teams/disable',
217
+ '/invite/accept',
218
+ '/join/request',
219
+ '/sso/proof',
220
+ '/sso/bypass',
221
+ '/sessions/end',
222
+ '/sign-out-everywhere',
223
+ ]);
224
+ /** The pages a sign-in may come back to: one made to confirm it's you, or to open an invitation. */
225
+ const STEP_UP_PAGES = new Set([
226
+ '/settings/privacy',
227
+ '/keys',
228
+ '/webhooks',
229
+ '/plan',
230
+ '/settings',
231
+ '/settings/members',
232
+ '/settings/team',
233
+ '/settings/identity',
234
+ '/teams',
235
+ '/contacts',
236
+ '/invite',
121
237
  ]);
122
238
  const SIGN_IN_AGAIN = 'That sign-in did not finish here, or took too long. Sign in again.';
123
239
  const GITHUB_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
124
240
  const MAX_COOKIE_CHARACTERS = 4_096;
125
241
  const GITHUB_TIMEOUT_MS = 10_000;
242
+ /** The most a team's token endpoint may answer: an ID token and its companions fit well within. */
243
+ const SSO_TOKEN_ANSWER_BYTES = 65_536;
126
244
  /** A Google account's subject: ASCII, at most 255 characters (OpenID Connect); Google's are digits. */
127
245
  const GOOGLE_SUBJECT = /^[A-Za-z0-9._-]{1,255}$/;
128
246
  /** The REST API version GitHub documents for GET /user (docs.github.com, read 2026-10-10). */
@@ -135,6 +253,196 @@ const BASE64URL = /^[A-Za-z0-9_-]+$/;
135
253
  const GITHUB_LOGIN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
136
254
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
137
255
  const DAY_MS = 86_400_000;
256
+ /** The invitation link being opened, for the sign-in that accepts it: sealed, for 30 minutes. */
257
+ const INVITE_COOKIE = '__Host-slotlock-invite';
258
+ /** A sign-in through a team's identity provider, until its callback. */
259
+ const SSO_COOKIE = '__Host-slotlock-sso';
260
+ const INVITE_LINK_TTL_MS = 30 * 60_000;
261
+ /** An invitation's token: 32 random bytes, base64url. Only its SHA-256 digest is stored. */
262
+ const INVITE_TOKEN = /^[A-Za-z0-9_-]{43}$/;
263
+ /** How long an invitation stays open, and how many a team workspace may send in a day. */
264
+ const INVITATION_DAYS = 7;
265
+ const INVITATION_DAILY_LIMIT = 50;
266
+ /** People in a team workspace on the Free plan, its Owner and open invitations included. */
267
+ const FREE_TEAM_MEMBERS = 3;
268
+ /** Team workspaces on the Free plan each person may create, where plans apply. */
269
+ const FREE_TEAMS_PER_PERSON = 1;
270
+ /** A team workspace's id, as its tenant names it (`team:<id>`). */
271
+ const TEAM_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
272
+ const EMAIL_ADDRESS = /^[^@\s<>"]{1,64}@[^@\s<>"]+\.[^@\s<>"]+$/;
273
+ /** Audit events a page of the audit log shows, and an export holds at most. */
274
+ const AUDIT_PAGE = 50;
275
+ const AUDIT_EXPORT_MOST = 10_000;
276
+ const AUDIT_EXPORT_EMPTY = Object.freeze({
277
+ occurred_at: '', action: '', outcome: '', actor_type: '', actor_ref: '', actor_role: '', target_type: '',
278
+ target_ref: '', reason: '', ip_address: '', user_agent: '', metadata: '', chain_index: '', hash: '',
279
+ });
280
+ /** A CSV cell, quoted; one a spreadsheet would read as a formula starts with an apostrophe. */
281
+ function csvCell(value) {
282
+ const safe = /^[\s]*[=+\-@]|^[\t\r\n]/.test(value) ? `'${value}` : value;
283
+ return `"${safe.replaceAll('"', '""')}"`;
284
+ }
285
+ /** What each audited action did, as the audit log says it. */
286
+ const AUDIT_ACTION_WORDS = Object.freeze({
287
+ 'api_key.created': 'created an API key',
288
+ 'api_key.rotated': 'rotated an API key',
289
+ 'api_key.revoked': 'revoked an API key',
290
+ 'api_key.limits_changed': "changed an API key's limits",
291
+ 'webhook.created': 'added a webhook',
292
+ 'webhook.deleted': 'removed a webhook',
293
+ 'webhook.enabled': 'turned a webhook back on',
294
+ 'contact.forgotten': 'forgot a contact',
295
+ 'subject.erased': 'erased a person',
296
+ 'export.created': 'exported the account',
297
+ 'export.downloaded': 'downloaded an export of the account',
298
+ 'subject.exported': "exported a person's data",
299
+ 'assistant.settings_saved': "changed the assistant's model",
300
+ 'assistant.settings_removed': "removed the assistant's model",
301
+ 'billing.checkout_started': 'started a checkout',
302
+ 'billing.portal_opened': 'opened billing',
303
+ 'organization.enabled': 'turned the organization on',
304
+ 'organization.disabled': 'turned the organization off',
305
+ 'sandbox.reset': 'reset the test environment',
306
+ 'session.signed_in': 'signed in',
307
+ 'session.signed_out_everywhere': 'signed out everywhere',
308
+ 'session.ended': 'signed a device out',
309
+ 'audit.exported': 'exported the audit log',
310
+ 'workspace.created': 'created the team',
311
+ 'workspace.deletion_scheduled': 'asked for the team to be deleted',
312
+ 'workspace.deletion_cancelled': 'kept the team',
313
+ 'member.invited': 'invited someone',
314
+ 'invitation.resent': 'sent an invitation again',
315
+ 'invitation.revoked': 'revoked an invitation',
316
+ 'member.joined': 'joined',
317
+ 'member.role_changed': "changed a member's role",
318
+ 'member.scope_changed': 'changed what a member reaches',
319
+ 'member.removed': 'removed a member',
320
+ 'member.left': 'left',
321
+ 'member.join_requested': 'asked to join',
322
+ 'member.join_approved': 'let someone join',
323
+ 'member.join_declined': 'declined a request to join',
324
+ 'domain.claimed': 'added a domain',
325
+ 'domain.verified': 'verified a domain',
326
+ 'domain.record_missing': "found a domain's record gone",
327
+ 'domain.lapsing': 'found a domain lapsing',
328
+ 'domain.restored': 'verified a domain again',
329
+ 'domain.lapsed': 'found a domain lapsed',
330
+ 'domain.released': 'removed a domain',
331
+ 'domain.join_policy_changed': 'changed who may join through a domain',
332
+ 'workspace.invites_restricted': 'held invitations to verified domains',
333
+ 'workspace.invites_unrestricted': 'let invitations go to any address',
334
+ 'sso.connection_saved': 'saved the single sign-on connection',
335
+ 'sso.connection_tested': 'tested single sign-on',
336
+ 'sso.connection_activated': 'turned single sign-on on',
337
+ 'sso.connection_disabled': 'turned single sign-on off',
338
+ 'sso.connection_deleted': 'deleted the single sign-on connection',
339
+ 'sso.provisioning_changed': 'changed who single sign-on adds',
340
+ 'sso.mapping_changed': 'changed a group’s role',
341
+ 'sso.required': 'required single sign-on',
342
+ 'sso.not_required': 'stopped requiring single sign-on',
343
+ 'sso.bypass_used': 'bypassed single sign-on',
344
+ 'sso.signed_in': 'signed in with single sign-on',
345
+ 'sso.linked': 'linked a single sign-on account',
346
+ 'authz.denied': 'was refused',
347
+ });
348
+ const AUDIT_ACTION_GROUPS = Object.freeze([
349
+ ['API keys', ['api_key.created', 'api_key.rotated', 'api_key.revoked', 'api_key.limits_changed']],
350
+ ['Webhooks', ['webhook.created', 'webhook.deleted', 'webhook.enabled']],
351
+ ['People', ['workspace.created', 'workspace.deletion_scheduled', 'workspace.deletion_cancelled', 'member.invited', 'invitation.resent', 'invitation.revoked', 'member.joined', 'member.role_changed', 'member.scope_changed', 'member.removed', 'member.left', 'member.join_requested', 'member.join_approved', 'member.join_declined', 'organization.enabled', 'organization.disabled']],
352
+ ['Identity', [
353
+ ...SLOTLOCK_DOMAIN_AUDIT_ACTIONS,
354
+ 'sso.connection_saved', 'sso.connection_tested', 'sso.connection_activated', 'sso.connection_disabled',
355
+ 'sso.connection_deleted', 'sso.provisioning_changed', 'sso.mapping_changed', 'sso.required', 'sso.not_required',
356
+ 'sso.bypass_used', 'sso.signed_in', 'sso.linked',
357
+ ]],
358
+ ['Billing', ['billing.checkout_started', 'billing.portal_opened']],
359
+ ['Sessions', ['session.signed_in', 'session.signed_out_everywhere', 'session.ended']],
360
+ ['Other', ['contact.forgotten', 'subject.erased', 'subject.exported', 'export.created', 'export.downloaded', 'assistant.settings_saved', 'assistant.settings_removed', 'sandbox.reset', 'audit.exported', 'authz.denied']],
361
+ ]);
362
+ const AUDIT_ACTION_NAMES = new Set(AUDIT_ACTION_GROUPS.flatMap(([, actions]) => actions));
363
+ /** What a team change refused says, and its status. */
364
+ const TEAM_REFUSALS = Object.freeze({
365
+ invalid_handle: { status: 400, text: 'That name gives no usable handle. Try another.' },
366
+ invalid_name: { status: 400, text: 'Name the team: 1 to 80 characters.' },
367
+ handle_taken: { status: 409, text: 'That name is taken. Try another.' },
368
+ team_limit: {
369
+ status: 403,
370
+ text: `Each person may create ${String(FREE_TEAMS_PER_PERSON)} team on the Free plan. Upgrade yours to create another.`,
371
+ },
372
+ not_found: { status: 404, text: 'That is no longer here.' },
373
+ not_allowed: { status: 403, text: 'Your role can’t give that role, or change that member.' },
374
+ already_member: { status: 409, text: 'They are already a member.' },
375
+ invitation_limit: {
376
+ status: 429,
377
+ text: `This team sent ${String(INVITATION_DAILY_LIMIT)} invitations in the last day. Try again tomorrow.`,
378
+ },
379
+ recipient_limit: {
380
+ status: 429,
381
+ text: `That address was sent ${String(SLOTLOCK_INVITATIONS_PER_ADDRESS)} invitations in the last day. Try again tomorrow.`,
382
+ },
383
+ member_cap: {
384
+ status: 403,
385
+ text: `A team on the Free plan has up to ${String(FREE_TEAM_MEMBERS)} people, open invitations included. Upgrade it to add more.`,
386
+ },
387
+ revoked: { status: 409, text: 'This invitation was withdrawn.' },
388
+ accepted: { status: 409, text: 'This invitation was already used.' },
389
+ expired: { status: 409, text: 'This invitation expired. Ask for a new one.' },
390
+ not_for_you: { status: 403, text: 'This invitation is for another account.' },
391
+ last_owner: { status: 409, text: 'A team keeps at least one owner. Make someone else an owner first.' },
392
+ sso_owner: { status: 409, text: 'An owner signs in with GitHub or Google too, for when the team’s provider is down. Ask them to link it first.' },
393
+ confirm: { status: 400, text: "Type the team's handle to confirm." },
394
+ subscription_live: { status: 409, text: 'Cancel its Team plan on Usage first.' },
395
+ owner: { status: 409, text: 'An owner reaches the whole workspace. Change their role first.' },
396
+ domain_restricted: { status: 403, text: 'Invitations go only to addresses at this team’s verified domains.' },
397
+ });
398
+ /** A handle from a team's name: lower-case letters, digits and dashes; later attempts add a random end. */
399
+ function teamHandleFrom(name, attempt) {
400
+ const base = name
401
+ .normalize('NFKD')
402
+ .replace(/[\u0300-\u036f]/g, '')
403
+ .toLowerCase()
404
+ .replace(/[^a-z0-9]+/g, '-')
405
+ .replace(/^-+/, '')
406
+ .slice(0, 30)
407
+ .replace(/-+$/, '') || 'team';
408
+ return attempt === 0 ? base : `${base}-${randomBytes(3).toString('hex')}`;
409
+ }
410
+ /** A browser as people name it, from its User-Agent: `Firefox on macOS`. */
411
+ function deviceOf(agent) {
412
+ if (!agent)
413
+ return 'A browser';
414
+ const browser = /Edg\//.test(agent)
415
+ ? 'Edge'
416
+ : /OPR\//.test(agent)
417
+ ? 'Opera'
418
+ : /Firefox\//.test(agent)
419
+ ? 'Firefox'
420
+ : /Chrome\//.test(agent)
421
+ ? 'Chrome'
422
+ : /Safari\//.test(agent)
423
+ ? 'Safari'
424
+ : 'A browser';
425
+ const system = /iPhone|iPad/.test(agent)
426
+ ? 'iOS'
427
+ : /Android/.test(agent)
428
+ ? 'Android'
429
+ : /Mac OS X|Macintosh/.test(agent)
430
+ ? 'macOS'
431
+ : /Windows/.test(agent)
432
+ ? 'Windows'
433
+ : /Linux/.test(agent)
434
+ ? 'Linux'
435
+ : '';
436
+ return system ? `${browser} on ${system}` : browser;
437
+ }
438
+ /** An address as an invitation shows it before anyone signs in: its first letter and domain. */
439
+ function maskedEmail(email) {
440
+ const at = email.lastIndexOf('@');
441
+ if (at < 1)
442
+ return email;
443
+ return `${email.slice(0, 1)}${'•'.repeat(Math.min(3, at - 1))}${email.slice(at)}`;
444
+ }
445
+ const tokenDigest = (raw) => createHash('sha256').update(raw).digest();
138
446
  const KEY_ACCESS = Object.freeze({
139
447
  read: ['read'],
140
448
  read_write: ['read', 'write'],
@@ -174,11 +482,34 @@ function policyFor(billing) {
174
482
  * The dashboard's resources, in the store under each tenant's row-level security. Adds hold the cap
175
483
  * in the database (a READ COMMITTED transaction, under a per-tenant lock).
176
484
  */
177
- export function createSlotlockDashboardResources(store, options = {}) {
485
+ export function createSlotlockDashboardResources(given, options = {}) {
486
+ // Every transaction keeps to the resources the request's member is limited to, if any.
487
+ const store = {
488
+ ...given,
489
+ withTenant(tenantRef, callback, options) {
490
+ const scope = slotlockDashboardRequestScope();
491
+ return given.withTenant(tenantRef, callback, scope ? { ...options, resourceScope: scope } : options);
492
+ },
493
+ };
178
494
  // Every change here is the dashboard's, made by the person signed in (actor) when known.
179
495
  const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
180
496
  const agentBackend = options.agentBackend;
497
+ /**
498
+ * Approvals the request's member may see: every one, or under a scope those whose every resource
499
+ * the scope reaches (approvals are the tenant's, not a resource's, so the database cannot tell).
500
+ */
501
+ const reachable = async (tenant, tenantRef, approvals) => {
502
+ if (!slotlockDashboardRequestScope())
503
+ return approvals;
504
+ const reached = new Set();
505
+ for (const id of new Set(approvals.flatMap((approval) => approval.resourceIds))) {
506
+ if (await tenant.getResource({ tenantRef, id }))
507
+ reached.add(id);
508
+ }
509
+ return approvals.filter((approval) => approval.resourceIds.every((id) => reached.has(id)));
510
+ };
181
511
  return {
512
+ keepsToScope: true,
182
513
  sandbox: {
183
514
  seed: (tenantRef, scenario) => seedSlotlockSandbox(store, tenantRef, scenario),
184
515
  async reset(tenantRef) {
@@ -192,9 +523,18 @@ export function createSlotlockDashboardResources(store, options = {}) {
192
523
  ...(agentBackend
193
524
  ? {
194
525
  approvals: {
195
- list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listApprovals({ tenantRef, limit })),
526
+ list: (tenantRef, limit) => store.withTenant(tenantRef, async (tenant) => reachable(tenant, tenantRef, await tenant.listApprovals({ tenantRef, limit }))),
196
527
  async decide(tenantRef, id, decision, actor, reason) {
197
528
  const decidedBy = actor ?? 'dashboard';
529
+ // A member limited to some resources decides only what asks for theirs alone.
530
+ if (slotlockDashboardRequestScope()) {
531
+ const found = await store.withTenant(tenantRef, async (tenant) => {
532
+ const approval = await tenant.getApproval({ tenantRef, id });
533
+ return approval ? (await reachable(tenant, tenantRef, [approval])).length > 0 : false;
534
+ });
535
+ if (!found)
536
+ return { ok: false, code: 'approval_not_found' };
537
+ }
198
538
  if (decision === 'decline') {
199
539
  const declined = await store.withTenant(tenantRef, (tenant) => tenant.declineApproval({ tenantRef, id, decidedBy, ...(reason ? { reason } : {}) }), by(actor));
200
540
  if (!declined.ok)
@@ -248,6 +588,22 @@ export function createSlotlockDashboardResources(store, options = {}) {
248
588
  const updated = await tenant.setResourceAvailability({ tenantRef, id: target.id, rules });
249
589
  return updated ? 1 : 0;
250
590
  }, by(actor)),
591
+ privacy: {
592
+ async createExport(tenantRef, actor) {
593
+ const made = await store.withTenant(tenantRef, (tenant) => tenant.createTenantExport({ tenantRef, ...(actor ? { requestedBy: actor } : {}) }), { isolation: 'read committed', ...by(actor) });
594
+ // Built after the answer; maintenance builds one a stopped server left pending.
595
+ void buildSlotlockTenantExport(store, tenantRef, made.id);
596
+ return made;
597
+ },
598
+ listExports: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listTenantExports({ tenantRef })),
599
+ exportPart: (tenantRef, exportId, part) => store.withTenant(tenantRef, (tenant) => tenant.getTenantExportPart({ tenantRef, exportId, part })),
600
+ deleteExport: (tenantRef, exportId) => store.withTenant(tenantRef, (tenant) => tenant.deleteTenantExport({ tenantRef, exportId })),
601
+ exportSubject: (tenantRef, subject) => store.withTenant(tenantRef, (tenant) => tenant.exportSubject({ tenantRef, subject, limit: 1_000 })),
602
+ eraseSubject: (tenantRef, subject, actor) => store.withTenant(tenantRef, (tenant) => tenant.eraseSubject({ tenantRef, subject }), {
603
+ isolation: 'read committed',
604
+ ...by(actor),
605
+ }),
606
+ },
251
607
  contacts: {
252
608
  list: (tenantRef, limit, after) => store.withTenant(tenantRef, (tenant) => tenant.listContacts({ tenantRef, limit, ...(after ? { after } : {}) })),
253
609
  forget: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteContact({ tenantRef, ref }), { isolation: 'read committed', ...by(actor) }),
@@ -344,6 +700,11 @@ export function createSlotlockDashboardResources(store, options = {}) {
344
700
  enable: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.enableWebhookEndpoint({ tenantRef, id }), by(actor)),
345
701
  },
346
702
  assistant: createSlotlockAssistantStore(store),
703
+ audit: {
704
+ verify: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.verifyAuditChain({ tenantRef })),
705
+ record: (tenantRef, event) => store.withTenant(tenantRef, (tenant) => tenant.recordAuditEvent({ tenantRef, event })),
706
+ list: (tenantRef, params = {}) => store.withTenant(tenantRef, (tenant) => tenant.listAuditEvents({ tenantRef, ...params })),
707
+ },
347
708
  };
348
709
  }
349
710
  function assertExpiry(expiresAt) {
@@ -547,6 +908,7 @@ const INHERITED_NAMES = Object.freeze({
547
908
  pickupTimes: 'Pick-up times',
548
909
  returnTimes: 'Return times',
549
910
  cancellationFee: 'Cancellation fee',
911
+ holdPrice: 'Hold price',
550
912
  hoursApply: 'Hours hold a hire at',
551
913
  });
552
914
  /** A rule's value as the dashboard shows it. */
@@ -840,7 +1202,7 @@ function readCookies(request) {
840
1202
  if (index <= 0)
841
1203
  continue;
842
1204
  const name = part.slice(0, index).trim();
843
- if (name === SESSION_COOKIE || name === SIGN_IN_COOKIE) {
1205
+ if (name === SESSION_COOKIE || name === SIGN_IN_COOKIE || name === INVITE_COOKIE || name === SSO_COOKIE) {
844
1206
  cookies.set(name, part.slice(index + 1).trim());
845
1207
  }
846
1208
  }
@@ -1045,6 +1407,24 @@ export function createSlotlockDashboard(options) {
1045
1407
  }
1046
1408
  // Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
1047
1409
  const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
1410
+ const workspaces = options.workspaces ?? null;
1411
+ // Team workspaces: with the membership store's database, where people have workspaces of their own.
1412
+ const teamStore = options.teamWorkspaces && !served ? (workspaces?.teams ?? null) : null;
1413
+ /** Verified domains: a team workspace's, with the database. */
1414
+ const domainStore = teamStore && options.domains ? (workspaces?.domains ?? null) : null;
1415
+ const domainResolvers = options.domains?.resolvers ?? [];
1416
+ /** Single sign-on: with verified domains and the database. */
1417
+ const ssoStore = domainStore && options.sso ? (workspaces?.sso ?? null) : null;
1418
+ const scimStore = domainStore && options.scim ? (workspaces?.scim ?? null) : null;
1419
+ const ssoFetch = (input, init) => (options.sso?.fetch ?? fetchImpl)(input, init);
1420
+ /** Teams' providers' keys, fetched as their Owners' URLs are: through ssoFetch. */
1421
+ const ssoJwks = createSlotlockJwksCache({ fetch: ssoFetch, now: () => now() });
1422
+ /** A session's team that requires single sign-on, met while it held no proof: to say so. */
1423
+ const ssoRequiredOf = new WeakMap();
1424
+ /** Each session's role in the workspace it acts in, as the database said when it was read. */
1425
+ const roles = new WeakMap();
1426
+ /** Where each request came from, for the audit log. */
1427
+ const origins = new WeakMap();
1048
1428
  // Plans whether or not billing sells them; billing's own reader when only it is given.
1049
1429
  const plans = options.plans ??
1050
1430
  (options.billing
@@ -1066,6 +1446,12 @@ export function createSlotlockDashboard(options) {
1066
1446
  const readsOrganizations = served !== null || teams !== null;
1067
1447
  /** Each session's organizations turned on, read with it (teams mode). */
1068
1448
  const enabledOf = new WeakMap();
1449
+ /** The team workspaces the session's person is a member of, read with their standing. */
1450
+ const teamListOf = new WeakMap();
1451
+ /** When the session's team, being deleted, goes. */
1452
+ const deletingOf = new WeakMap();
1453
+ /** The locations and groups each session's member is limited to (RFC 0004 D1), read with their standing. */
1454
+ const scopes = new WeakMap();
1069
1455
  const brandName = options.name ?? 'Slotlock';
1070
1456
  if (!SLOTLOCK_DASHBOARD_NAME_PATTERN.test(brandName) || brandName.trim() !== brandName) {
1071
1457
  throw new Error('Slotlock dashboard name must be 1-60 characters, without <, > or control or invisible characters');
@@ -1087,7 +1473,8 @@ export function createSlotlockDashboard(options) {
1087
1473
  // A list, or exactly '*': any other string would match ids by substring.
1088
1474
  // A GitHub person by their bare id, a Google one as google:<subject>.
1089
1475
  const allowed = (uid, idp) => options.allowedUsers === '*' ||
1090
- (Array.isArray(options.allowedUsers) && options.allowedUsers.includes(idp === 'google' ? `google:${uid}` : uid));
1476
+ // A person a team's provider made is no account a list names: only where everyone may sign in.
1477
+ (idp !== 'sso' && Array.isArray(options.allowedUsers) && options.allowedUsers.includes(idp === 'google' ? `google:${uid}` : uid));
1091
1478
  const google = served ? null : (options.google ?? null);
1092
1479
  let signInsInFlight = 0;
1093
1480
  /** Sign-ins in flight by client address. */
@@ -1100,8 +1487,12 @@ export function createSlotlockDashboard(options) {
1100
1487
  // A failing reporter must not change the response.
1101
1488
  }
1102
1489
  }
1103
- // Bound to the organization too: a page opened before a switch cannot write into the new one.
1104
- const formMac = (session, nonce, org = session.org ?? '') => createHmac('sha256', secret)
1490
+ /** The workspace a form is bound to: its organization's id, `team:<id>`, or '' for one's own. */
1491
+ function workspaceKeyOf(session) {
1492
+ return session.team !== undefined ? `team:${session.team}` : (session.org ?? '');
1493
+ }
1494
+ // Bound to the workspace too: a page opened before a switch cannot write into the new one.
1495
+ const formMac = (session, nonce, org = workspaceKeyOf(session)) => createHmac('sha256', secret)
1105
1496
  .update(`slotlock-dashboard-form-v2.${session.sid}.${org}.${nonce}`)
1106
1497
  .digest('base64url');
1107
1498
  /** Random, and bound to the session by a MAC: a made-up or another session's value is refused unwritten. */
@@ -1118,8 +1509,13 @@ export function createSlotlockDashboard(options) {
1118
1509
  // Compared as text, like the cookie MACs: decoding would drop the last character's spare bits.
1119
1510
  if (safeEqual(mac, formMac(session, nonce)))
1120
1511
  return 'ok';
1121
- const other = session.orgs?.some((org) => org.id !== session.org && safeEqual(mac, formMac(session, nonce, org.id)));
1122
- return other ? 'other_org' : 'bad';
1512
+ const current = workspaceKeyOf(session);
1513
+ const others = [
1514
+ ...(session.orgs ?? []).map((org) => org.id),
1515
+ ...(teamListOf.get(session) ?? []).map((ws) => ws.tenantRef),
1516
+ ...(teamStore ? [''] : []),
1517
+ ].filter((other) => other !== current);
1518
+ return others.some((other) => safeEqual(mac, formMac(session, nonce, other))) ? 'other_org' : 'bad';
1123
1519
  };
1124
1520
  function seal(context, payload) {
1125
1521
  const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
@@ -1156,26 +1552,145 @@ export function createSlotlockDashboard(options) {
1156
1552
  typeof payload.uid !== 'string' ||
1157
1553
  typeof payload.login !== 'string' ||
1158
1554
  typeof payload.sid !== 'string' ||
1159
- (payload.idp !== undefined && (payload.idp !== 'google' || !google)) ||
1555
+ (payload.idp !== undefined && !((payload.idp === 'google' && google) || (payload.idp === 'sso' && ssoStore))) ||
1556
+ (payload.sc !== undefined && (!ssoStore || typeof payload.sc !== 'string' || !UUID_PATTERN.test(payload.sc))) ||
1160
1557
  !allowed(payload.uid, payload.idp) ||
1161
1558
  !organizationsHold(payload) ||
1559
+ !teamHolds(payload) ||
1162
1560
  (payload.env !== undefined && payload.env !== 'test') ||
1163
1561
  (await options.state.sessionEnded(payload.sid, payload.exp))) {
1164
1562
  return null;
1165
1563
  }
1166
- const session = payload;
1564
+ let session = payload;
1167
1565
  if (teams) {
1168
1566
  // Which of the person's organizations are on now: one turned off since is left at once.
1169
1567
  const enabled = session.orgs?.length ? await teams.enabled(session.orgs.map((org) => org.id)) : new Set();
1170
1568
  if (session.org !== undefined && !enabled.has(session.org)) {
1171
1569
  const { org: _, ...personal } = session;
1172
- enabledOf.set(personal, enabled);
1173
- return personal;
1570
+ session = personal;
1174
1571
  }
1175
1572
  enabledOf.set(session, enabled);
1176
1573
  }
1177
- return session;
1574
+ return await withStanding(session);
1178
1575
  }
1576
+ /**
1577
+ * The session with the person's role in its workspace as the database says now (workspaces): one
1578
+ * signed out everywhere since it was sealed is refused, and one whose membership ended falls back
1579
+ * to the person's own workspace or, where there is none (a fixed list of organizations), ends.
1580
+ */
1581
+ async function withStanding(session) {
1582
+ if (!workspaces) {
1583
+ roles.set(session, derivedRole(session));
1584
+ return session;
1585
+ }
1586
+ const person = personOf(session);
1587
+ const [standing, listed, alive, deleting, scope] = await Promise.all([
1588
+ workspaces.standing(tenantOf(session), person, session.sid),
1589
+ teamStore ? teamStore.workspacesOf(person) : Promise.resolve([]),
1590
+ workspaces.sessions ? workspaces.sessions.seen(person, session.sid) : Promise.resolve(true),
1591
+ teamStore && session.team !== undefined ? teamStore.deleteAfter(`team:${session.team}`) : Promise.resolve(null),
1592
+ workspaces.scope && sharedWorkspace(session) ? workspaces.scope(tenantOf(session), person) : Promise.resolve(null),
1593
+ ]);
1594
+ if (deleting)
1595
+ deletingOf.set(session, deleting);
1596
+ // Sealed under the person's current epoch, or refused: one from before Sign out everywhere,
1597
+ // from before this store, or of a person erased since (no record, no epoch) never passes; nor
1598
+ // one the person signed out from another device.
1599
+ if (!alive || session.ep === undefined || standing.sessionEpoch === null || session.ep !== standing.sessionEpoch)
1600
+ return null;
1601
+ if (standing.role !== null) {
1602
+ roles.set(session, standing.role);
1603
+ // A session through a team's provider alone knows of no other team the person is in.
1604
+ teamListOf.set(session, session.sc === undefined ? listed : listed.filter((team) => team.tenantRef === `team:${session.team}`));
1605
+ // Limited to some resources: what this request reads for them from now on keeps to those.
1606
+ const limited = scope && standing.role !== 'owner' ? scope : null;
1607
+ if (limited)
1608
+ scopes.set(session, limited);
1609
+ setSlotlockDashboardRequestScope(limited);
1610
+ return session;
1611
+ }
1612
+ // Signed in through a team's provider alone, or as a person it made: no workspace to fall back to.
1613
+ if ((session.org === undefined && session.team === undefined) || served || session.sc !== undefined || session.idp === 'sso')
1614
+ return null;
1615
+ const { org: _, team: _team, ...personal } = session;
1616
+ if (standing.refusal === 'sso_required' && session.team !== undefined) {
1617
+ ssoRequiredOf.set(personal, { tenantRef: `team:${session.team}`, mayBypass: standing.mayBypass === true });
1618
+ }
1619
+ const enabled = enabledOf.get(session);
1620
+ if (enabled)
1621
+ enabledOf.set(personal, enabled);
1622
+ teamListOf.set(personal, listed);
1623
+ roles.set(personal, 'owner');
1624
+ setSlotlockDashboardRequestScope(null);
1625
+ return personal;
1626
+ }
1627
+ /** A team workspace only with team workspaces on, never beside an organization; addresses as kept. */
1628
+ function teamHolds(payload) {
1629
+ if (payload.team !== undefined &&
1630
+ (!teamStore || typeof payload.team !== 'string' || !TEAM_ID.test(payload.team) || payload.org !== undefined)) {
1631
+ return false;
1632
+ }
1633
+ if (payload.hd !== undefined && (payload.idp !== 'google' || typeof payload.hd !== 'string' || payload.hd.length > 253))
1634
+ return false;
1635
+ if (payload.em === undefined)
1636
+ return true;
1637
+ return (Array.isArray(payload.em) &&
1638
+ payload.em.length <= 3 &&
1639
+ payload.em.every((email) => typeof email === 'string' && email.length <= 254));
1640
+ }
1641
+ /** A workspace people share (an organization's or a team's), not a person's own. */
1642
+ const sharedWorkspace = (session) => session.org !== undefined || session.team !== undefined;
1643
+ /** The team workspace the session acts in (`team:<id>`, Live), or undefined. */
1644
+ const teamTenantOf = (session) => (session.team !== undefined ? `team:${session.team}` : undefined);
1645
+ const currentTeam = (session) => session.team === undefined ? undefined : teamListOf.get(session)?.find((ws) => ws.tenantRef === `team:${session.team}`);
1646
+ /** Addresses the person holds verified: their Google account's, or those GitHub confirmed for an invitation. */
1647
+ const verifiedEmails = (session) =>
1648
+ // A session through a team's provider alone holds no address verified for anything else.
1649
+ session.sc !== undefined
1650
+ ? []
1651
+ : session.idp === 'google'
1652
+ ? session.login.includes('@')
1653
+ ? [session.login.toLowerCase()]
1654
+ : []
1655
+ : (session.em ?? []);
1656
+ /**
1657
+ * How those addresses were verified, for joining through a domain: by a Google Workspace account
1658
+ * of the address's own domain (the company controls it now), or once, by Google or GitHub.
1659
+ */
1660
+ const joinEvidence = (session) => {
1661
+ if (session.idp !== 'google')
1662
+ return 'github';
1663
+ const domain = slotlockEmailDomain(session.login);
1664
+ return session.hd !== undefined && domain !== null && domain === slotlockEmailDomain(`x@${session.hd}`) ? 'google_workspace' : 'google';
1665
+ };
1666
+ /**
1667
+ * Without a membership store: the Owner of one's own workspace; in an organization's, its GitHub
1668
+ * owners are Owners and its other members Developers, as GitHub said at sign-in.
1669
+ */
1670
+ function derivedRole(session) {
1671
+ if (session.org === undefined)
1672
+ return 'owner';
1673
+ return session.orgs?.find((org) => org.id === session.org)?.role === 'admin' ? 'owner' : 'developer';
1674
+ }
1675
+ const roleOf = (session) => roles.get(session) ?? derivedRole(session);
1676
+ /**
1677
+ * What the person may do: their role's capabilities, and limited to some resources only those
1678
+ * SLOTLOCK_SCOPED_CAPABILITIES keeps, and only their own account's where the resource adapters
1679
+ * cannot keep a request to their resources.
1680
+ */
1681
+ const can = (session, capability) => {
1682
+ const limited = scopes.has(session);
1683
+ if (limited && !options.resources.keepsToScope && capability !== 'account.use')
1684
+ return false;
1685
+ return slotlockMemberCan(roleOf(session), limited, capability);
1686
+ };
1687
+ /** Whether the person reaches a page: its capability, and limited, the pages a limited member has. */
1688
+ const reaches = (session, page) => {
1689
+ const capability = SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES[page];
1690
+ return capability !== undefined && can(session, capability) && (!scopes.has(session) || SLOTLOCK_SCOPED_DASHBOARD_PAGES.has(page));
1691
+ };
1692
+ /** Signed in within the last 15 minutes: what a fresh capability needs. */
1693
+ const freshlySignedIn = (session) => now() - (session.exp - SESSION_TTL_MS) <= FRESH_SIGN_IN_MS;
1179
1694
  /**
1180
1695
  * With organizations, the session names one the server still serves, from its own list; without
1181
1696
  * them, it names none. A session from the other mode is signed out.
@@ -1200,13 +1715,17 @@ export function createSlotlockDashboard(options) {
1200
1715
  .update(`slotlock-dashboard-csrf-v1.${session.sid}`)
1201
1716
  .digest('base64url');
1202
1717
  /** The tenant the session acts in: the workspace's, or its test environment's (`~test`). */
1203
- const tenantOf = (session) => `${session.org !== undefined ? `github-org:${session.org}` : personOf(session)}${session.env === 'test' ? SLOTLOCK_TEST_TENANT_SUFFIX : ''}`;
1718
+ const tenantOf = (session) => `${session.team !== undefined
1719
+ ? `team:${session.team}`
1720
+ : session.org !== undefined
1721
+ ? `github-org:${session.org}`
1722
+ : personOf(session)}${session.env === 'test' ? SLOTLOCK_TEST_TENANT_SUFFIX : ''}`;
1204
1723
  /** Who did something, for the record: the person, whatever tenant they act in. */
1205
1724
  function personOf(session) {
1206
- return session.idp === 'google' ? `google:${session.uid}` : `github:${session.uid}`;
1725
+ return session.idp === 'google' ? `google:${session.uid}` : session.idp === 'sso' ? `sso:${session.uid}` : `github:${session.uid}`;
1207
1726
  }
1208
- /** How the dashboard shows the person: their GitHub login, or their Google account's email. */
1209
- const shownAs = (session) => (session.idp === 'google' ? session.login : `@${session.login}`);
1727
+ /** How the dashboard shows the person: their GitHub login, their Google account's email, or their provider's. */
1728
+ const shownAs = (session) => session.idp === 'google' || session.idp === 'sso' || session.sc !== undefined ? session.login : `@${session.login}`;
1210
1729
  function respond(status, body, headers = {}, cookies = []) {
1211
1730
  const response = new Response(String(body), {
1212
1731
  status,
@@ -1246,7 +1765,7 @@ export function createSlotlockDashboard(options) {
1246
1765
  ? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
1247
1766
  : []),
1248
1767
  ...(plans ? [{ section: 'plan', label: 'Usage', path: '/plan', icon: ICONS.plan }] : []),
1249
- ...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
1768
+ ...(teams || teamStore ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
1250
1769
  { section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
1251
1770
  ];
1252
1771
  /** Live or Test (RFC 0003 G9): the same workspace, a tenant of its own for trying things. */
@@ -1257,12 +1776,15 @@ export function createSlotlockDashboard(options) {
1257
1776
  /** The workspace the session acts in: its organization, or the person themselves. */
1258
1777
  function workspace(session) {
1259
1778
  const current = session.orgs?.find((org) => org.id === session.org);
1779
+ const team = currentTeam(session);
1260
1780
  const choices = (session.orgs ?? []).filter((org) => served?.has(org.id));
1261
- const name = current?.login ?? session.login;
1781
+ const name = team?.name ?? current?.login ?? session.login;
1262
1782
  const badge = html `<span class="avatar" aria-hidden="true">${name.slice(0, 1).toUpperCase()}</span>`;
1263
1783
  const enabled = teams ? (session.orgs ?? []).filter((org) => enabledOf.get(session)?.has(org.id)) : [];
1264
- if (teams && enabled.length > 0) {
1265
- 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>`;
1784
+ const listed = teamListOf.get(session) ?? [];
1785
+ if (enabled.length > 0 || listed.length > 0) {
1786
+ const personal = !current && !team;
1787
+ 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=""${personal ? html ` selected` : html ``}>${shownAs(session)}</option>${enabled.map((org) => html `<option value="${org.id}"${org.id === current?.id ? html ` selected` : html ``}>${org.login}</option>`)}${listed.map((ws) => html `<option value="${ws.tenantRef}"${ws.tenantRef === team?.tenantRef ? html ` selected` : html ``}>${ws.name}</option>`)}</select><button class="quiet" type="submit">Switch</button></form>`;
1266
1788
  }
1267
1789
  if (current && choices.length > 1) {
1268
1790
  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>`;
@@ -1296,7 +1818,9 @@ ${live}
1296
1818
  </body>
1297
1819
  </html>`;
1298
1820
  }
1299
- const links = nav().map((item) => html `<a href="${root}${item.path}"${item.section === section ? html ` aria-current="page"` : html ``}>${item.icon}<span>${item.label}</span></a>`);
1821
+ const links = nav()
1822
+ .filter((item) => reaches(session, item.path || '/'))
1823
+ .map((item) => html `<a href="${root}${item.path}"${item.section === section ? html ` aria-current="page"` : html ``}>${item.icon}<span>${item.label}</span></a>`);
1300
1824
  return html `${head}
1301
1825
  <body>
1302
1826
  ${skip}
@@ -1308,6 +1832,8 @@ ${skip}
1308
1832
  </header>
1309
1833
  <main class="page" id="main">${session.env === 'test'
1310
1834
  ? 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>`
1835
+ : html ``}${deletingOf.has(session)
1836
+ ? html `<p class="test-banner deleting-banner">This team is deleted on ${formatDate(deletingOf.get(session))}; its keys are refused.${can(session, 'workspace.own') ? html ` <a href="${root}/settings/team">Keep it</a>` : html ``}</p>`
1311
1837
  : html ``}${content}</main>
1312
1838
  </div>
1313
1839
  ${live}
@@ -1327,7 +1853,7 @@ ${live}
1327
1853
  <h1>Sign in to ${brandName}</h1>
1328
1854
  <p>Create keys for your agents, add what they book, and watch what they do.</p>
1329
1855
  ${noticeOf(notice, status)}
1330
- <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 ``}
1856
+ <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 ``}${ssoStore ? html `<a class="button wide" href="${root}/sso">Continue with single sign-on</a>` : html ``}
1331
1857
  ${options.termsUrl
1332
1858
  ? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
1333
1859
  ? html ` The <a href="${options.privacyUrl}">Privacy notice</a> says what is kept.`
@@ -1355,7 +1881,7 @@ ${options.termsUrl
1355
1881
  ]
1356
1882
  .filter(Boolean)
1357
1883
  .join(', ');
1358
- function keyRows(keys, token, session) {
1884
+ function keyRows(keys, token, session, names = new Map()) {
1359
1885
  return keys.map((key) => {
1360
1886
  const expired = key.expiresAt !== null && key.expiresAt.getTime() <= now();
1361
1887
  const status = key.revokedAt ? 'Revoked' : expired ? 'Expired' : 'Active';
@@ -1368,7 +1894,7 @@ ${options.termsUrl
1368
1894
  <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>`
1369
1895
  : html ``;
1370
1896
  return html `<tr>
1371
- <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>
1897
+ <td data-label="Name" class="strong"><span class="cell"><span class="truncate" title="${key.name}">${key.name}</span>${sharedWorkspace(session) ? html `<span class="meta">Created by ${key.createdBy === personOf(session) ? 'you' : (key.createdBy && names.get(key.createdBy)) ?? (key.createdBy?.startsWith('github:') ? `GitHub user ${key.createdBy.slice(7)}` : key.createdBy?.startsWith('google:') ? 'a Google user' : 'someone unknown')}</span>` : html ``}</span></td>
1372
1898
  <td data-label="Key"><code class="chip">${key.prefix}…</code></td>
1373
1899
  <td data-label="Access">${key.resourceScope || key.limits
1374
1900
  ? 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>`
@@ -1533,8 +2059,10 @@ ${options.termsUrl
1533
2059
  const start = new Date(now());
1534
2060
  const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
1535
2061
  const assistant = assistantStore();
2062
+ // A member limited to some resources sees theirs: no keys, plan or setup, which are the workspace's.
2063
+ const limited = scopes.has(session);
1536
2064
  const [keys, resources, agenda, changes, approvals, waitlist, model] = await Promise.all([
1537
- options.keys.list({ tenantRef }),
2065
+ limited ? Promise.resolve([]) : options.keys.list({ tenantRef }),
1538
2066
  options.resources.list(tenantRef),
1539
2067
  options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
1540
2068
  options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
@@ -1543,7 +2071,7 @@ ${options.termsUrl
1543
2071
  assistant?.getSettings(tenantRef),
1544
2072
  ]);
1545
2073
  // The plan is Live's: the test environment has none of its own.
1546
- const standing = plans && session.env !== 'test' ? await planStanding(session, 'overview', keys) : null;
2074
+ const standing = plans && session.env !== 'test' && !limited ? await planStanding(session, 'overview', keys) : null;
1547
2075
  const byId = new Map(resources.map((resource) => [resource.id, resource]));
1548
2076
  const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
1549
2077
  const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
@@ -1562,7 +2090,7 @@ ${options.termsUrl
1562
2090
  : []),
1563
2091
  ];
1564
2092
  const admin = isAdmin(session);
1565
- const steps = [
2093
+ const steps = limited ? [] : [
1566
2094
  { done: resources.length > 0, title: 'Add a resource', action: { href: `${root}/resources`, label: 'Add resource' } },
1567
2095
  { done: active.length > 0, title: 'Create an API key', action: { href: `${root}/keys#new-key`, label: 'Create key' } },
1568
2096
  // Done once an agent has used a key.
@@ -1578,7 +2106,7 @@ ${options.termsUrl
1578
2106
  const done = steps.filter((step) => step.done).length;
1579
2107
  const next = steps.findIndex((step) => !step.done);
1580
2108
  // Nothing made and nothing happened yet: only the way in.
1581
- const fresh = resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
2109
+ const fresh = !limited && resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
1582
2110
  const setupCard = next === -1
1583
2111
  ? html ``
1584
2112
  : html `<section class="panel setup-card"><div class="panel-head"><h2>Get started</h2><span class="meta">${String(done)} of ${String(steps.length)}</span></div><div class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</div><ol class="setup-steps">${steps.map((step, index) => html `<li class="${step.done ? 'done' : index === next ? 'next' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span><span class="step-title">${step.title}${step.done ? html `<span class="visually-hidden"> (done)</span>` : html ``}</span>${index === next ? html `<a class="button primary" href="${step.action.href}">${step.action.label}</a>` : html ``}</li>`)}</ol></section>`;
@@ -1592,21 +2120,21 @@ ${options.termsUrl
1592
2120
  ...(approvals ? [stat('Waiting for approval', String(waiting), `${root}/approvals`, waiting > 0 ? ' attention-stat' : '')] : []),
1593
2121
  ...(waitlist ? [stat('On the waitlist', String(waitlist.filter((entry) => entry.status !== 'expired').length), `${root}/waitlist`)] : []),
1594
2122
  stat('Resources', String(resources.length), `${root}/resources`),
1595
- stat('Active keys', String(active.length), `${root}/keys`),
2123
+ ...(limited ? [] : [stat('Active keys', String(active.length), `${root}/keys`)]),
1596
2124
  ].slice(0, 4);
1597
2125
  const planCard = standing
1598
2126
  ? html `<section class="panel plan-card"><div class="panel-head"><h2>${standing.name}</h2><a href="${root}/plan">Usage</a></div><div class="meters stacked">${standing.meters}</div>${standing.canUpgrade && standing.pressing
1599
2127
  ? html `<a class="button primary" href="${root}/plan#upgrade">Upgrade</a>`
1600
2128
  : html ``}</section>`
1601
2129
  : html ``;
1602
- const connectCard = html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
2130
+ const connectCard = limited ? html `` : html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
1603
2131
  const body = fresh
1604
2132
  ? standing
1605
2133
  ? html `<div class="split">${setupCard}<div class="column">${planCard}</div></div>`
1606
2134
  : setupCard
1607
2135
  : html `${done * 2 >= steps.length ? setupRow : setupCard}
1608
2136
  <div class="stats">${tiles}</div>
1609
- ${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
2137
+ ${session.env === 'test' && options.resources.sandbox && can(session, 'sandbox.manage') ? sandboxPanel(session, resources.length) : html ``}
1610
2138
  <div class="split">
1611
2139
  <div class="column">
1612
2140
  ${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
@@ -1617,7 +2145,7 @@ ${changes ? html `<section class="panel"><h2>Activity</h2>${activityPanel(change
1617
2145
  const content = html `${noticeOf(notice, status)}
1618
2146
  ${pageHead('Overview')}
1619
2147
  ${attentionList(attention)}
1620
- ${fresh && session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
2148
+ ${fresh && session.env === 'test' && options.resources.sandbox && can(session, 'sandbox.manage') ? sandboxPanel(session, resources.length) : html ``}
1621
2149
  ${body}`;
1622
2150
  return respond(status, layout('Overview', content, session, 'overview'));
1623
2151
  }
@@ -1671,11 +2199,14 @@ ${resourceCount === 0
1671
2199
  async function keysPage(session, status = 200, notice) {
1672
2200
  const tenantRef = tenantOf(session);
1673
2201
  const grouping = options.resources.grouping;
1674
- const [keys, locations, groups] = await Promise.all([
2202
+ const team = teamTenantOf(session);
2203
+ const [keys, locations, groups, members] = await Promise.all([
1675
2204
  options.keys.list({ tenantRef }),
1676
2205
  grouping?.locations(tenantRef),
1677
2206
  grouping?.groups(tenantRef),
2207
+ teamStore && team !== undefined ? teamStore.members(team) : Promise.resolve([]),
1678
2208
  ]);
2209
+ const names = new Map(members.map((member) => [member.person, memberName(member)]));
1679
2210
  const token = csrfToken(session);
1680
2211
  const paths = locationPaths(locations ?? []);
1681
2212
  // A key limited to locations or groups (RFC 0003 G6), when the tenant has any to choose.
@@ -1704,8 +2235,8 @@ ${limitChoices}
1704
2235
  </section>
1705
2236
  ${keys.length === 0
1706
2237
  ? emptyState(ICONS.keys, 'No keys yet. Give every agent its own, with the least access it needs.', { href: '#key-name', label: 'Create a key' })
1707
- : 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>
1708
- <tbody>${keyRows(keys, token, session)}</tbody></table></div>`}`;
2238
+ : 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>
2239
+ <tbody>${keyRows(keys, token, session, names)}</tbody></table></div>`}`;
1709
2240
  return respond(status, layout('API keys', content, session, 'keys'));
1710
2241
  }
1711
2242
  /**
@@ -1831,20 +2362,55 @@ ${groups.length === 0
1831
2362
  /** Settings' sections, each its own page: the assistant's form apart from what is only read. */
1832
2363
  const SETTINGS_SECTIONS = Object.freeze([
1833
2364
  { id: 'assistant', label: 'Assistant', path: '/settings' },
2365
+ { id: 'members', label: 'Members', path: '/settings/members' },
2366
+ { id: 'team', label: 'Team', path: '/settings/team' },
2367
+ { id: 'identity', label: 'Identity', path: '/settings/identity' },
2368
+ { id: 'audit', label: 'Audit log', path: '/settings/audit' },
2369
+ { id: 'privacy', label: 'Privacy', path: '/settings/privacy' },
1834
2370
  { id: 'endpoints', label: 'Endpoints', path: '/settings/endpoints' },
1835
2371
  { id: 'hours', label: 'Hours', path: '/settings/hours' },
1836
2372
  { id: 'account', label: 'Account', path: '/settings/account' },
1837
2373
  ]);
1838
- async function settingsPage(session, status = 200, notice, draft, section = 'assistant') {
2374
+ async function settingsPage(session, status = 200, notice, draft, section = 'assistant', invitation, query, found) {
1839
2375
  const store = assistantStore();
1840
- const sections = SETTINGS_SECTIONS.filter((entry) => entry.id !== 'assistant' || store);
1841
- const shown = section === 'assistant' && !store ? 'endpoints' : section;
2376
+ // Members are a team workspace's: its people come from invitations, not from GitHub.
2377
+ const sections = SETTINGS_SECTIONS.filter((entry) => (entry.id !== 'assistant' || store) &&
2378
+ (entry.id !== 'members' || (teamStore && session.team !== undefined)) &&
2379
+ (entry.id !== 'team' || (teamStore && session.team !== undefined && can(session, 'workspace.own'))) &&
2380
+ (entry.id !== 'identity' || (domainStore && session.team !== undefined && can(session, 'identity.manage'))) &&
2381
+ (entry.id !== 'audit' || (options.resources.audit && can(session, 'audit.read'))) &&
2382
+ (entry.id !== 'privacy' || (options.resources.privacy && can(session, 'privacy.manage'))) &&
2383
+ // Limited to some resources: one's account and the team's people, nothing of the workspace's.
2384
+ (!scopes.has(session) || entry.id === 'account' || entry.id === 'members'));
2385
+ const shown = scopes.has(session) && !sections.some((entry) => entry.id === section)
2386
+ ? 'account'
2387
+ : section === 'assistant' && !store
2388
+ ? 'endpoints'
2389
+ : (section === 'members' || section === 'audit' || section === 'team' || section === 'privacy' || section === 'identity') &&
2390
+ !sections.some((entry) => entry.id === section)
2391
+ ? 'account'
2392
+ : section;
1842
2393
  const connected = store ? Boolean(await store.getSettings(tenantOf(session))) : false;
1843
2394
  const nav = html `<nav class="subnav" aria-label="Settings">${sections.map((entry) => html `<a href="${root}${entry.path}"${entry.id === shown ? html ` aria-current="page"` : html ``}>${entry.label}${entry.id === 'assistant' && connected ? html `<span class="nav-dot" aria-hidden="true"></span>` : html ``}</a>`)}</nav>`;
1844
2395
  let body;
1845
2396
  if (shown === 'assistant') {
1846
2397
  body = await assistantSettingsSection(session, status, notice, draft);
1847
2398
  }
2399
+ else if (shown === 'members') {
2400
+ body = await membersSection(session, status, notice, invitation);
2401
+ }
2402
+ else if (shown === 'team') {
2403
+ body = await teamSection(session, status, notice);
2404
+ }
2405
+ else if (shown === 'identity') {
2406
+ body = await identitySection(session, status, notice);
2407
+ }
2408
+ else if (shown === 'audit') {
2409
+ body = await auditSection(session, status, notice, query ?? new URLSearchParams());
2410
+ }
2411
+ else if (shown === 'privacy') {
2412
+ body = await privacySection(session, status, notice, found);
2413
+ }
1848
2414
  else if (shown === 'endpoints') {
1849
2415
  body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">MCP</span><div class="row-value">${copyField('mcp-url', mcpUrl)}</div></div><div class="row"><span class="row-label">A2A</span><div class="row-value">${copyField('a2a-url', a2aUrl)}</div></div></div>`;
1850
2416
  }
@@ -1856,14 +2422,47 @@ ${groups.length === 0
1856
2422
  }
1857
2423
  else {
1858
2424
  const current = session.orgs?.find((org) => org.id === session.org);
2425
+ const team = currentTeam(session);
1859
2426
  const others = (session.orgs ?? []).filter((org) => org.id !== current?.id);
1860
- body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Signed in as</span><div class="row-value">${shownAs(session)}</div></div><div class="row"><span class="row-label">${current ? 'Organization' : 'Workspace'}</span><div class="row-value">${current ? current.login : 'Personal'}</div></div>${current && others.length > 0
2427
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Signed in as</span><div class="row-value">${shownAs(session)}</div></div><div class="row"><span class="row-label">${current ? 'Organization' : team ? 'Team' : 'Workspace'}</span><div class="row-value">${current ? current.login : team ? team.name : 'Personal'}</div></div>${current && others.length > 0
1861
2428
  ? html `<div class="row"><span class="row-label">Also a member of</span><div class="row-value">${others.map((org) => org.login).join(', ')}</div></div>`
1862
- : html ``}<div class="row"><span class="row-label">Session ends</span><div class="row-value">${formatDate(new Date(session.exp))}</div></div></div>${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}`;
2429
+ : html ``}<div class="row"><span class="row-label">Role</span><div class="row-value">${SLOTLOCK_WORKSPACE_ROLE_NAMES[roleOf(session)]}</div></div><div class="row"><span class="row-label">Session ends</span><div class="row-value">${formatDate(new Date(session.exp))}</div></div>${workspaces && session.sc === undefined
2430
+ ? html `<div class="row"><span class="row-label">Sessions</span><div class="row-value"><form method="post" action="${root}/sign-out-everywhere"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Sign out everywhere</button></form></div></div>`
2431
+ : html ``}</div>${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}${await sessionsList(session)}`;
1863
2432
  }
1864
2433
  const content = html `${pageHead('Settings')}<div class="settings">${nav}<div class="settings-body">${body}</div></div>`;
1865
2434
  return respond(status, layout('Settings', content, session, 'settings'));
1866
2435
  }
2436
+ /** Where the person is signed in, each but this one with Sign out. */
2437
+ async function sessionsList(session) {
2438
+ const kept = workspaces?.sessions;
2439
+ if (!kept)
2440
+ return html ``;
2441
+ const person = personOf(session);
2442
+ const mine = kept.idOf(session.sid);
2443
+ // A session through a team's provider alone sees only itself: the person's other sessions (and
2444
+ // where they were signed in from) are beyond its team.
2445
+ const listed = (await kept.list(person)).filter((entry) => session.sc === undefined || entry.id === mine);
2446
+ if (listed.length === 0)
2447
+ return html ``;
2448
+ const rows = listed.map((entry) => html `<tr><td data-label="Device" class="strong">${deviceOf(entry.userAgent)}${entry.id === mine ? html ` <span class="pill active">This device</span>` : html ``}</td><td data-label="Address">${entry.ipAddress ?? '—'}</td><td data-label="Signed in">${formatDate(entry.startedAt)}</td><td data-label="Last seen">${formatDate(entry.lastSeenAt)}</td><td class="actions">${entry.id === mine
2449
+ ? html ``
2450
+ : html `<form class="inline" method="post" action="${root}/sessions/end"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${entry.id}"><button type="submit">Sign out<span class="visually-hidden"> ${deviceOf(entry.userAgent)}, signed in ${formatDate(entry.startedAt)}</span></button></form>`}</td></tr>`);
2451
+ return html `<h2 class="sessions-head">Signed in</h2><div class="table"><table><thead><tr><th>Device</th><th>Address</th><th>Signed in</th><th>Last seen</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`;
2452
+ }
2453
+ async function endSession(session, form) {
2454
+ const kept = workspaces?.sessions;
2455
+ if (!kept)
2456
+ return nothingHere(session);
2457
+ const id = (form.get('id') ?? '').toLowerCase();
2458
+ if (id === kept.idOf(session.sid)) {
2459
+ return settingsPage(session, 400, 'To sign out of this device, use Sign out.', undefined, 'account');
2460
+ }
2461
+ if (!(await kept.end(personOf(session), id))) {
2462
+ return settingsPage(session, 404, 'That session has ended already.', undefined, 'account');
2463
+ }
2464
+ return settingsPage(session, 200, 'Signed out of that device.', undefined, 'account');
2465
+ }
1867
2466
  /** A value to copy: monospace, cut with an ellipsis, with a Copy button (app.js). */
1868
2467
  const copyField = (id, value) => html `<dl class="endpoints"><dd><code id="${id}">${value}</code><button class="quiet" type="button" data-copy="${id}">Copy</button></dd></dl>`;
1869
2468
  /** The tenant's resource with that id and every resource (for names), or null for anything else. */
@@ -1938,11 +2537,12 @@ ${field('defaultDurationMinutes', 'Usual length, offered when none is asked', 'm
1938
2537
  ${field('cancelCutoffMinutes', 'Cancel up to, before the start', 'min')}
1939
2538
  ${field('rescheduleCutoffMinutes', 'Move up to, before the start', 'min')}
1940
2539
  </div>
1941
- ${resource.bookingRules?.allowedDurations || resource.bookingRules?.pickupTimes || resource.bookingRules?.returnTimes || resource.bookingRules?.cancellationFee ? html `<p class="fine">Set through the API, and kept when you save: ${[
2540
+ ${resource.bookingRules?.allowedDurations || resource.bookingRules?.pickupTimes || resource.bookingRules?.returnTimes || resource.bookingRules?.cancellationFee || resource.bookingRules?.holdPrice ? html `<p class="fine">Set through the API, and kept when you save: ${[
1942
2541
  resource.bookingRules.allowedDurations ? `lengths ${ruleValueText(resource.bookingRules.allowedDurations)} min` : '',
1943
2542
  resource.bookingRules.pickupTimes ? `pick-up ${ruleValueText(resource.bookingRules.pickupTimes)}` : '',
1944
2543
  resource.bookingRules.returnTimes ? `return ${ruleValueText(resource.bookingRules.returnTimes)}` : '',
1945
2544
  resource.bookingRules.cancellationFee ? `cancellation fee ${ruleValueText(resource.bookingRules.cancellationFee)}` : '',
2545
+ resource.bookingRules.holdPrice ? `hold price ${ruleValueText(resource.bookingRules.holdPrice)}` : '',
1946
2546
  ].filter(Boolean).join('; ')}.</p>` : html ``}
1947
2547
  ${toggle('enforceHours', 'enforce_hours', 'Only inside bookable hours')}
1948
2548
  ${toggle('requireCertain', 'require_certain', 'Only in time its calendar syncs have read today')}
@@ -2053,7 +2653,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
2053
2653
  if (requireCertain !== undefined)
2054
2654
  rules.requireCertain = requireCertain;
2055
2655
  // Rules the form does not edit (set through the API) are kept.
2056
- for (const name of ['allowedDurations', 'pickupTimes', 'returnTimes', 'cancellationFee']) {
2656
+ for (const name of ['allowedDurations', 'pickupTimes', 'returnTimes', 'cancellationFee', 'holdPrice']) {
2057
2657
  const kept = resource.bookingRules?.[name];
2058
2658
  if (kept !== undefined)
2059
2659
  rules[name] = kept;
@@ -2306,7 +2906,9 @@ ${endpoints.length === 0
2306
2906
  const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
2307
2907
  if (!created.ok) {
2308
2908
  const message = created.code === 'webhook_limit_reached'
2309
- ? `You have ${String(created.limit)} webhooks, as many as ${plans ? 'your plan' : 'Slotlock'} allows. Delete one first${plans ? ', or see Usage' : ''}.`
2909
+ ? plans
2910
+ ? html `You have ${String(created.limit)} webhooks, as many as your plan allows. Delete one first, or ${await pastLimit(session)}.`
2911
+ : `You have ${String(created.limit)} webhooks, as many as Slotlock allows. Delete one first.`
2310
2912
  : created.code === 'invalid_webhook_url'
2311
2913
  ? 'Give an https URL, with no user name, password or #fragment.'
2312
2914
  : 'Choose events from the list.';
@@ -2619,11 +3221,17 @@ ${decided.length === 0
2619
3221
  }
2620
3222
  /** Owners of the organization in use may change its assistant; a person always may their own. */
2621
3223
  /** An owner of the workspace: the person in their own, an organization's owner in its. */
2622
- const isAdmin = (session) => session.org === undefined || session.orgs?.find((org) => org.id === session.org)?.role === 'admin';
3224
+ /** May connect or change the assistant's model (Owner, Admin). */
3225
+ const isAdmin = (session) => can(session, 'assistant.settings');
3226
+ /** May change the plan and open billing (Owner, Billing). */
3227
+ const mayBill = (session) => can(session, 'billing.manage');
3228
+ /** Never more than the person may do: a Viewer's assistant only reads; it never holds admin. */
2623
3229
  const assistantPrincipal = (session) => ({
2624
3230
  subject: assistantActor(session),
2625
3231
  tenantRef: tenantOf(session),
2626
- scopes: ['read', 'write'],
3232
+ scopes: can(session, 'operations.run') ? ['read', 'write'] : ['read'],
3233
+ // Limited to some resources, as a key limited to them: nothing outside is found.
3234
+ ...(scopes.has(session) ? { resourceScope: scopes.get(session) } : {}),
2627
3235
  });
2628
3236
  /** The fixed list, then the embedder's check: nothing the model sends adds to it. */
2629
3237
  const assistantAuthorize = async (args) => SLOTLOCK_ASSISTANT_TOOL_USE[args.operation] !== 'excluded' &&
@@ -3121,7 +3729,7 @@ ${decided.length === 0
3121
3729
  return { provider: preset.provider, baseUrl: preset.baseUrl };
3122
3730
  return { provider: form.get('provider'), baseUrl: (form.get('base_url') ?? '').trim() };
3123
3731
  }
3124
- const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
3732
+ const adminOnly = (session) => settingsPage(session, 403, 'Only an owner or an admin of this workspace can change the assistant.');
3125
3733
  async function saveAssistantSettings(session, form) {
3126
3734
  const store = assistantStore();
3127
3735
  if (!store || !assistantOptions)
@@ -3627,7 +4235,7 @@ ${decided.length === 0
3627
4235
  ? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
3628
4236
  : null;
3629
4237
  const keyCount = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
3630
- const admin = isAdmin(session);
4238
+ const admin = mayBill(session);
3631
4239
  const paying = plan !== null && plan.plan !== 'free';
3632
4240
  const upcoming = !plan && planDefault && planDefault.appliesFrom.getTime() > now() ? planDefault : null;
3633
4241
  const limits = plan?.limits ?? upcoming?.limits;
@@ -3636,8 +4244,10 @@ ${decided.length === 0
3636
4244
  const counted = (value) => value.toLocaleString('en-GB');
3637
4245
  const usagePage = `${root}/plan`;
3638
4246
  const upgradeAt = here === 'usage' ? '#upgrade' : `${usagePage}#upgrade`;
3639
- const canUpgrade = !paying && admin && Boolean(billing || active.contact);
3640
- const upgradeHint = paying ? '' : admin ? ' Upgrade for more.' : ' An owner of this organization can upgrade.';
4247
+ // Only where the deployment sells plans (Stripe) or arranges them (a contact) is there an upgrade.
4248
+ const offersPlans = Boolean(billing || active.contact);
4249
+ const canUpgrade = !paying && admin && offersPlans;
4250
+ const upgradeHint = paying || !offersPlans ? '' : admin ? ' Upgrade for more.' : ' An owner of this organization can upgrade.';
3641
4251
  const attention = [];
3642
4252
  // Whether anything is near or past a limit: the overview then offers the upgrade.
3643
4253
  let pressing = false;
@@ -3661,22 +4271,35 @@ ${decided.length === 0
3661
4271
  attention.push(attentionItem('warn', `You have used this month's ${counted(quota)} ${metric}.${billed ? ` Those past it are billed at ${billing?.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.${billed ? '' : upgradeHint}`, billed ? undefined : upgrade));
3662
4272
  }
3663
4273
  else if (spent * 100 >= quota * 80) {
3664
- attention.push(attentionItem('warn', `You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${metric}. They start again on ${resets}.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
4274
+ attention.push(attentionItem('warn', `You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${metric}. They start again on ${resets}.`, upgrade));
3665
4275
  }
3666
4276
  }
3667
4277
  }
4278
+ // What the workspace has against its plan's limits (or the coming plan's): at one, adding another
4279
+ // is refused; past one (a plan that allows less than it has), nothing is removed.
4280
+ const full = limits
4281
+ ? [
4282
+ ['resources', resourceCount, limits.resources],
4283
+ ['active API keys', keyCount, limits.apiKeys],
4284
+ ...(webhooks ? [['webhooks', webhooks.length, limits.webhookEndpoints]] : []),
4285
+ ].filter(([, have, allowed]) => have >= allowed)
4286
+ : [];
4287
+ const over = full.filter(([, have, allowed]) => have > allowed);
4288
+ const holding = full.map(([what, have, allowed]) => `${counted(have)} of ${counted(allowed)} ${what}`).join(' and ');
4289
+ if (full.length > 0)
4290
+ pressing = true;
4291
+ const limitAction = canUpgrade
4292
+ ? { href: upgradeAt, label: 'Upgrade' }
4293
+ : here === 'overview'
4294
+ ? { href: usagePage, label: 'Usage' }
4295
+ : undefined;
3668
4296
  if (upcoming) {
3669
- // What the workspace has that its coming plan will not allow: nothing is removed; adding stops.
3670
- const over = [
3671
- ['resources', resourceCount, upcoming.limits.resources],
3672
- ['active API keys', keyCount, upcoming.limits.apiKeys],
3673
- ['webhooks', webhooks?.length ?? 0, upcoming.limits.webhookEndpoints],
3674
- ].filter(([, have, allowed]) => have > allowed);
3675
- if (over.length > 0)
3676
- pressing = true;
3677
- attention.push(attentionItem(over.length > 0 ? 'warn' : 'info', `From ${formatDate(upcoming.appliesFrom)}, this workspace is on ${PLAN_NAMES[upcoming.plan]}: ${String(upcoming.limits.resources)} resources, ${String(upcoming.limits.apiKeys)} API keys and ${String(upcoming.limits.webhookEndpoints)} webhooks.${over.length > 0
3678
- ? ` You have ${over.map(([what, have]) => `${String(have)} ${what}`).join(', ')}: nothing is removed, but you cannot add more until you have fewer${paying || !admin ? '' : ', or upgrade'}.`
3679
- : ''}`, canUpgrade && over.length > 0 ? { href: upgradeAt, label: 'See plans' } : undefined));
4297
+ attention.push(attentionItem(full.length > 0 ? 'warn' : 'info', `From ${formatDate(upcoming.appliesFrom)}, this workspace is on ${PLAN_NAMES[upcoming.plan]}: ${String(upcoming.limits.resources)} resources, ${String(upcoming.limits.apiKeys)} API keys and ${String(upcoming.limits.webhookEndpoints)} webhooks.${full.length > 0
4298
+ ? ` You have ${holding}: ${over.length > 0 ? 'nothing is removed, but ' : ''}from then, adding another is refused.${upgradeHint}`
4299
+ : ''}`, full.length > 0 ? limitAction : undefined));
4300
+ }
4301
+ else if (plan && full.length > 0) {
4302
+ attention.push(attentionItem(over.length > 0 ? 'bad' : 'warn', `On ${PLAN_NAMES[plan.plan]}, you have ${holding}: ${over.length > 0 ? 'nothing is removed, but ' : ''}adding another is refused.${upgradeHint}`, limitAction));
3680
4303
  }
3681
4304
  /** One use against its allowance: a bar, amber from 80%, red at the limit. */
3682
4305
  const meter = (label, value, limit) => {
@@ -3714,6 +4337,17 @@ ${decided.length === 0
3714
4337
  pressing,
3715
4338
  };
3716
4339
  }
4340
+ /**
4341
+ * What resolves an add refused at a plan limit, as a link: the upgrade on Usage, for an owner of a
4342
+ * workspace that does not pay yet where the deployment sells or arranges plans; else Usage.
4343
+ */
4344
+ async function pastLimit(session) {
4345
+ const plan = plans ? await plans.plan(tenantOf(session)).catch(() => null) : null;
4346
+ const paying = plan !== null && plan.plan !== 'free';
4347
+ return !paying && mayBill(session) && Boolean(options.billing || plans?.contact)
4348
+ ? html `<a href="${root}/plan#upgrade">upgrade</a>`
4349
+ : html `see <a href="${root}/plan">Usage</a>`;
4350
+ }
3717
4351
  /** One thing that needs a person: a sentence, its tone, and what resolves it. */
3718
4352
  const attentionItem = (tone, text, action) => html `<li class="attention ${tone}"><span class="attention-text">${text}</span>${action ? html `<a class="attention-action" href="${action.href}">${action.label}</a>` : html ``}</li>`;
3719
4353
  /** The list of things that need a person, or nothing. */
@@ -3739,7 +4373,7 @@ ${decided.length === 0
3739
4373
  const token = csrfToken(session);
3740
4374
  const keyName = new Map(keys.map((key) => [key.id, key.name]));
3741
4375
  // An organization is offered Team (where billing sells it, or by asking); a person, Developer.
3742
- const offered = session.org !== undefined && (billing ? Boolean(billing.teamPrices) : true) ? 'team' : 'developer';
4376
+ const offered = sharedWorkspace(session) && (billing ? Boolean(billing.teamPrices) : true) ? 'team' : 'developer';
3743
4377
  const contact = plans.contact ?? billing?.contact;
3744
4378
  const manage = billing && plan?.stripeCustomer && admin
3745
4379
  ? 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>`
@@ -3803,8 +4437,11 @@ ${contact
3803
4437
  return notFound(session);
3804
4438
  if (session.env === 'test')
3805
4439
  return planPage(session, 400, 'Plans are changed in Live.');
3806
- if (!isAdmin(session))
3807
- return planPage(session, 403, 'Only owners of the organization can change its plan.');
4440
+ if (!mayBill(session))
4441
+ return planPage(session, 403, 'Only an owner or a billing manager of this workspace can change its plan.');
4442
+ // A team being deleted takes no plan: kept first, so it is never billed while it waits to go.
4443
+ if (deletingOf.has(session))
4444
+ return planPage(session, 409, 'This team is being deleted. Keep it on Settings, Team before choosing a plan.');
3808
4445
  const interval = form.get('interval') === 'year' ? 'year' : 'month';
3809
4446
  const tenantRef = tenantOf(session);
3810
4447
  const plan = await billing.plan(tenantRef);
@@ -3816,7 +4453,7 @@ ${contact
3816
4453
  url = await billing.checkout({
3817
4454
  tenantRef,
3818
4455
  interval,
3819
- ...(session.org !== undefined && billing.teamPrices ? { plan: 'team' } : {}),
4456
+ ...(sharedWorkspace(session) && billing.teamPrices ? { plan: 'team' } : {}),
3820
4457
  customer: plan?.stripeCustomer ?? null,
3821
4458
  successUrl: `${publicRoot}/plan?checkout=done`,
3822
4459
  cancelUrl: `${publicRoot}/plan`,
@@ -3837,8 +4474,8 @@ ${contact
3837
4474
  return notFound(session);
3838
4475
  if (session.env === 'test')
3839
4476
  return planPage(session, 400, 'Billing is managed in Live.');
3840
- if (!isAdmin(session))
3841
- return planPage(session, 403, 'Only owners of the organization can manage its billing.');
4477
+ if (!mayBill(session))
4478
+ return planPage(session, 403, 'Only an owner or a billing manager of this workspace can manage its billing.');
3842
4479
  const plan = await billing.plan(tenantOf(session));
3843
4480
  if (!plan?.stripeCustomer)
3844
4481
  return planPage(session, 409, 'There is no billing to manage yet.');
@@ -3853,33 +4490,226 @@ ${contact
3853
4490
  return redirect(303, url);
3854
4491
  }
3855
4492
  /** The page a form was sent from, to show what went wrong with it. */
3856
- function pageFor(route) {
4493
+ /** Where a request came from, as the audit log keeps it: the client's address, and its browser. */
4494
+ function requestOrigin(request) {
4495
+ const address = options.clientAddress?.(request);
4496
+ const agent = request.headers.get('user-agent')?.slice(0, 256);
4497
+ return { ...(address ? { ipAddress: address.slice(0, 64) } : {}), ...(agent ? { userAgent: agent } : {}) };
4498
+ }
4499
+ /**
4500
+ * Record what the person did (or was refused) in the workspace's audit log. An action already done
4501
+ * is not undone because its record failed: the failure goes to onError.
4502
+ */
4503
+ async function audit(session, event, tenantRef = tenantOf(session)) {
4504
+ const log = options.resources.audit;
4505
+ if (!log)
4506
+ return;
4507
+ const from = origins.get(session) ?? {};
4508
+ try {
4509
+ await log.record(tenantRef, {
4510
+ actorType: 'person',
4511
+ actorRef: personOf(session),
4512
+ actorRole: roleOf(session),
4513
+ ...from,
4514
+ ...event,
4515
+ });
4516
+ }
4517
+ catch (error) {
4518
+ try {
4519
+ ignoreRejection(options.onError?.(error));
4520
+ }
4521
+ catch {
4522
+ // A failing reporter must not change the response.
4523
+ }
4524
+ }
4525
+ }
4526
+ /** What an audited form changed: its target, and what describes the change (never a secret). */
4527
+ function auditDetails(route, form) {
4528
+ const id = form.get('id') ?? '';
4529
+ if (route === '/keys') {
4530
+ return { targetType: 'api_key', metadata: { access: form.get('access') ?? '', expires: form.get('expires') ?? '' } };
4531
+ }
4532
+ if (route.startsWith('/keys/') && UUID_PATTERN.test(id))
4533
+ return { targetType: 'api_key', targetRef: id };
4534
+ if (route === '/webhooks') {
4535
+ let host = '';
4536
+ try {
4537
+ host = new URL(form.get('url') ?? '').host;
4538
+ }
4539
+ catch {
4540
+ // An address the form refused names no host.
4541
+ }
4542
+ return { targetType: 'webhook', metadata: host ? { host } : {} };
4543
+ }
4544
+ if (route.startsWith('/webhooks/') && UUID_PATTERN.test(id))
4545
+ return { targetType: 'webhook', targetRef: id };
4546
+ if (route.startsWith('/teams/')) {
4547
+ const org = form.get('organization') ?? '';
4548
+ return ORGANIZATION_ID.test(org) ? { targetType: 'organization', targetRef: `github-org:${org}` } : {};
4549
+ }
4550
+ if (route === '/settings/assistant') {
4551
+ return { metadata: { provider: (form.get('provider') ?? '').slice(0, 40), model: (form.get('model') ?? '').slice(0, 120) } };
4552
+ }
4553
+ return {};
4554
+ }
4555
+ /**
4556
+ * Every capability a form needs: its route's, and more where the form decides. A Full-access key
4557
+ * asks `keys.full`; a key someone else created asks `keys.manage_any`; rotating or limiting a
4558
+ * Full-access key asks `keys.full` as well, so a person who lost that power cannot keep it by
4559
+ * rotating, and anyone rotating one signed in recently. An empty list is a route with none.
4560
+ */
4561
+ async function capabilitiesOf(session, route, form) {
4562
+ const base = SLOTLOCK_DASHBOARD_POST_CAPABILITIES[route];
4563
+ if (base === undefined)
4564
+ return [];
4565
+ const needed = [base];
4566
+ if (route === '/keys' && form.get('access') === 'full')
4567
+ needed.push('keys.full');
4568
+ // Giving a role needs its powers: making an Owner `workspace.own`, a Billing manager `billing.manage`.
4569
+ if (route === '/members/invite' || route === '/members/role') {
4570
+ const role = form.get('role');
4571
+ if (role === 'owner')
4572
+ needed.push('workspace.own');
4573
+ if (role === 'billing')
4574
+ needed.push('billing.manage');
4575
+ }
4576
+ if (route === '/keys/rotate' || route === '/keys/revoke' || route === '/keys/limits') {
4577
+ // As PostgreSQL reads a uuid, whatever its case: the key the handler would act on.
4578
+ const id = (form.get('id') ?? '').toLowerCase();
4579
+ if (UUID_PATTERN.test(id)) {
4580
+ const key = (await options.keys.list({ tenantRef: tenantOf(session) })).find((candidate) => candidate.id.toLowerCase() === id);
4581
+ // A key not listed here is not one the person made: only who may change anyone's tries it.
4582
+ if (!key || key.createdBy !== personOf(session))
4583
+ needed.push('keys.manage_any');
4584
+ if (key?.scopes.includes('admin') && route !== '/keys/revoke')
4585
+ needed.push('keys.full');
4586
+ }
4587
+ }
4588
+ return needed;
4589
+ }
4590
+ /** The page a form's route belongs to, to go back to. */
4591
+ const backOf = (route) => {
4592
+ if (route.startsWith('/members/'))
4593
+ return '/settings/members';
4594
+ if (route.startsWith('/identity/'))
4595
+ return '/settings/identity';
4596
+ if (route.startsWith('/join/'))
4597
+ return '/teams';
4598
+ if (route === '/teams/delete' || route === '/teams/restore')
4599
+ return '/settings/team';
4600
+ if (route.startsWith('/settings/privacy'))
4601
+ return '/settings/privacy';
4602
+ const first = `/${route.split('/')[1] ?? ''}`;
4603
+ return first === '/' ? '' : first;
4604
+ };
4605
+ /** Refuse a form the person's role does not allow, or ask them to sign in again for a fresh one. */
4606
+ async function authorizePost(session, route, form) {
4607
+ // Signed in through a team's provider alone: the session acts in that team, and reaches no other
4608
+ // (joining, invitations, switching, creating, proving oneself elsewhere). The provider is the
4609
+ // team Owner's, so what it asserts must open nothing outside the team.
4610
+ if (session.sc !== undefined && SSO_SCOPED_REFUSED.has(route)) {
4611
+ await audit(session, { action: 'authz.denied', outcome: 'denied', reason: 'sso_scope', metadata: { route } });
4612
+ return respond(403, layout('Not allowed', html `<p class="notice" role="alert">You signed in with your team’s single sign-on, which reaches this team only. Sign in with GitHub or Google to do that.</p>`, session));
4613
+ }
4614
+ const needed = await capabilitiesOf(session, route, form);
4615
+ const missing = needed.length === 0 ? undefined : needed.find((capability) => !can(session, capability));
4616
+ const outOfScope = scopes.has(session) && !SLOTLOCK_SCOPED_DASHBOARD_POSTS.has(route);
4617
+ if (needed.length === 0 || missing !== undefined || outOfScope) {
4618
+ await audit(session, {
4619
+ action: 'authz.denied',
4620
+ outcome: 'denied',
4621
+ reason: missing ? `needs ${missing}` : needed.length === 0 ? 'not a listed route' : 'limited to locations and groups',
4622
+ metadata: { route },
4623
+ });
4624
+ return deniedPage(session, missing, backOf(route));
4625
+ }
4626
+ const fresh = needed.find((capability) => SLOTLOCK_FRESH_CAPABILITIES.has(capability));
4627
+ if (fresh !== undefined && !freshlySignedIn(session))
4628
+ return confirmPage(session, fresh, backOf(route));
4629
+ return null;
4630
+ }
4631
+ /** Refuse a page the person's role does not reach; a billing manager's overview is Usage. */
4632
+ function authorizePage(session, page) {
4633
+ const capability = SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES[page];
4634
+ if (reaches(session, page))
4635
+ return null;
4636
+ if (page === '/' && plans && can(session, 'usage.read'))
4637
+ return redirect(303, `${root}/plan`);
4638
+ return deniedPage(session, capability, plans && can(session, 'usage.read') ? '/plan' : '');
4639
+ }
4640
+ function deniedPage(session, capability, back) {
4641
+ const role = SLOTLOCK_WORKSPACE_ROLE_NAMES[roleOf(session)];
4642
+ const text = scopes.has(session) && (capability === undefined || slotlockRoleCan(roleOf(session), capability))
4643
+ ? 'Your access to this workspace is limited to some locations and groups, so this is not yours to see or change.'
4644
+ : capability
4645
+ ? `As ${/^[AEIOU]/.test(role) ? 'an' : 'a'} ${role} in this workspace, you can't ${CAPABILITY_WORDS[capability]}.`
4646
+ : 'Nothing is here.';
4647
+ return respond(403, layout('Not allowed', html `<p class="notice" role="alert">${text}</p><p><a href="${root}${back}">Back</a></p>`, session));
4648
+ }
4649
+ /** A fresh capability after more than 15 minutes: sign in again, then come back to the page. */
4650
+ function confirmPage(session, capability, back) {
4651
+ const query = new URLSearchParams({
4652
+ ...(session.idp === 'google' ? { with: 'google' } : {}),
4653
+ ...(STEP_UP_PAGES.has(back) ? { then: back } : {}),
4654
+ }).toString();
4655
+ return respond(403, layout('Confirm it’s you', html `${pageHead('Confirm it’s you')}<p>To ${CAPABILITY_WORDS[capability]}, sign in again: your last sign-in was more than 15 minutes ago.</p><p class="actions"><a class="button primary" href="${root}/sign-in${query ? `?${query}` : ''}">Sign in again</a> <a class="button" href="${root}${back}">Cancel</a></p>`, session));
4656
+ }
4657
+ /**
4658
+ * The page a form's route belongs to, and the page path whose capability reading it needs: the
4659
+ * page rendered, not the one the route names (without the store a page needs, it is the overview).
4660
+ */
4661
+ function pageEntryFor(route) {
3857
4662
  if (route.startsWith('/keys'))
3858
- return keysPage;
4663
+ return { path: '/keys', page: keysPage };
3859
4664
  if (route.startsWith('/webhooks') && options.resources.webhooks)
3860
- return webhooksPage;
4665
+ return { path: '/webhooks', page: webhooksPage };
3861
4666
  if (route.startsWith('/waitlist') && options.resources.waitlist)
3862
- return waitlistPage;
4667
+ return { path: '/waitlist', page: waitlistPage };
3863
4668
  if (route.startsWith('/approvals') && options.resources.approvals)
3864
- return approvalsPage;
4669
+ return { path: '/approvals', page: approvalsPage };
3865
4670
  if ((route.startsWith('/locations') || route.startsWith('/groups')) && options.resources.grouping) {
3866
- return locationsPage;
4671
+ return { path: '/locations', page: locationsPage };
3867
4672
  }
3868
4673
  if (route.startsWith('/closures') && options.resources.hours)
3869
- return closuresPage;
4674
+ return { path: '/closures', page: closuresPage };
3870
4675
  if (route.startsWith('/contacts') && options.resources.contacts)
3871
- return contactsPage;
4676
+ return { path: '/contacts', page: contactsPage };
3872
4677
  if (route.startsWith('/resources') || route === '/hours')
3873
- return resourcesPage;
4678
+ return { path: '/resources', page: resourcesPage };
3874
4679
  if (route.startsWith('/assistant') && assistantStore())
3875
- return assistantPage;
4680
+ return { path: '/assistant', page: assistantPage };
4681
+ if ((route === '/teams/delete' || route === '/teams/restore') && teamStore) {
4682
+ return { path: '/settings/team', page: (session, status, notice) => settingsPage(session, status, notice, undefined, 'team') };
4683
+ }
4684
+ if (route.startsWith('/members/') && teamStore) {
4685
+ return { path: '/settings/members', page: (session, status, notice) => settingsPage(session, status, notice, undefined, 'members') };
4686
+ }
4687
+ if (route.startsWith('/identity/') && domainStore) {
4688
+ return { path: '/settings/identity', page: (session, status, notice) => settingsPage(session, status, notice, undefined, 'identity') };
4689
+ }
4690
+ if (route.startsWith('/join/') && domainStore)
4691
+ return { path: '/teams', page: teamsPage };
3876
4692
  if (route.startsWith('/settings'))
3877
- return settingsPage;
4693
+ return { path: '/settings', page: settingsPage };
3878
4694
  if (route.startsWith('/plan') && plans)
3879
- return planPage;
3880
- if (route.startsWith('/teams') && teams)
3881
- return teamsPage;
3882
- return overviewPage;
4695
+ return { path: '/plan', page: planPage };
4696
+ if ((route.startsWith('/teams') || route.startsWith('/invite')) && (teams || teamStore))
4697
+ return { path: '/teams', page: teamsPage };
4698
+ return { path: '/', page: overviewPage };
4699
+ }
4700
+ /**
4701
+ * The page a form's route belongs to, for an answer about the form itself (already sent, too many
4702
+ * forms, another organization's page), only to a person whose role reads the page rendered:
4703
+ * anyone else gets the notice alone. These answers come before the role is checked.
4704
+ */
4705
+ function pageOrNotice(route) {
4706
+ const { path, page } = pageEntryFor(route);
4707
+ return (session, status, notice) => {
4708
+ const capability = SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES[path];
4709
+ if (capability !== undefined && can(session, capability))
4710
+ return page(session, status, notice);
4711
+ return respond(status, layout('Refused', html `<p class="notice" role="alert">${notice}</p>`, session));
4712
+ };
3883
4713
  }
3884
4714
  const isLocationTarget = (target) => typeof target === 'object' && 'location' in target && typeof target.location === 'object';
3885
4715
  /** How many resources keep hours of their own, a few by name, and what saving does to them. */
@@ -3979,6 +4809,7 @@ ${target === 'all' ? ownHoursChoice(state.own ?? []) : html ``}
3979
4809
  }
3980
4810
  const notFound = (session) => respond(404, layout('Not found', html `<p class="notice">That resource is not one of yours.</p>`, session));
3981
4811
  const nothingHere = (session) => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
4812
+ const nothingHere404 = () => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, null), { 'Referrer-Policy': 'no-referrer' });
3982
4813
  /** The tenant's resources in use that keep hours of their own, by name. */
3983
4814
  async function ownHoursOf(session) {
3984
4815
  const resources = await options.resources.list(tenantOf(session));
@@ -4332,9 +5163,39 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
4332
5163
  * Teams on Cloud: the person's GitHub organizations, as read at sign-in, each on or off. An owner
4333
5164
  * turns one on or off; a member asks an owner; one that is on is a workspace to switch to.
4334
5165
  */
4335
- async function teamsPage(session, status = 200, notice) {
4336
- if (!teams)
5166
+ async function teamsPage(session, status = 200, notice, checked = false) {
5167
+ if (!teams && !teamStore)
4337
5168
  return nothingHere(session);
5169
+ const own = teamStore ? await teamWorkspacesSection(session, checked) : html ``;
5170
+ const organizations = teams ? await organizationsSection(session) : html ``;
5171
+ const content = html `${noticeOf(notice, status)}${pageHead('Teams')}${own}${organizations}`;
5172
+ return respond(status, layout('Teams', content, session, 'teams'));
5173
+ }
5174
+ /** The person's team workspaces, the invitations open to them, and creating one. */
5175
+ async function teamWorkspacesSection(session, checked = false) {
5176
+ if (!teamStore)
5177
+ return html ``;
5178
+ const listed = teamListOf.get(session) ?? [];
5179
+ const [offered, atDomain] = await Promise.all([
5180
+ teamStore.pendingFor(personOf(session), verifiedEmails(session)),
5181
+ domainTeamsSection(session, checked),
5182
+ ]);
5183
+ const token = csrfToken(session);
5184
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
5185
+ const rows = listed.map((ws) => html `<tr><td data-label="Team" class="strong">${ws.name}</td><td data-label="Handle" class="muted">@${ws.handle}</td><td data-label="You are">${SLOTLOCK_WORKSPACE_ROLE_NAMES[ws.role]}</td><td class="actions">${ws.tenantRef === teamTenantOf(session)
5186
+ ? html `<span class="muted">In use</span>`
5187
+ : html `<form class="inline" method="post" action="${root}/organization">${hidden()}<input type="hidden" name="organization" value="${ws.tenantRef}"><button type="submit">Switch to it</button></form>`}</td></tr>`);
5188
+ const invitations = offered.map((invitation) => html `<tr><td data-label="Team" class="strong">${invitation.workspaceName}</td><td data-label="From">${invitation.inviterDisplay}</td><td data-label="As">${SLOTLOCK_WORKSPACE_ROLE_NAMES[invitation.role]}</td><td class="actions"><form class="inline" method="post" action="${root}/invite/accept">${hidden()}<input type="hidden" name="invitation" value="${invitation.id}"><button class="primary" type="submit">Join ${invitation.workspaceName}</button></form></td></tr>`);
5189
+ return html `<section class="team-workspaces" aria-labelledby="team-workspaces"><h2 id="team-workspaces">Team workspaces</h2>${invitations.length > 0
5190
+ ? html `<div class="table"><table><caption class="visually-hidden">Invitations</caption><thead><tr><th>Invited to</th><th>From</th><th>As</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${invitations}</tbody></table></div>`
5191
+ : html ``}${atDomain}${rows.length > 0
5192
+ ? html `<div class="table"><table><thead><tr><th>Team</th><th>Handle</th><th>You are</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`
5193
+ : html ``}<form class="create-team" method="post" action="${root}/teams/create">${hidden()}<label for="team-name">Name</label><input id="team-name" name="name" maxlength="80" autocomplete="organization" required><button class="primary" type="submit">Create team</button></form></section>`;
5194
+ }
5195
+ /** GitHub organizations turned on for Slotlock (organizations: 'enabled'). */
5196
+ async function organizationsSection(session) {
5197
+ if (!teams)
5198
+ return html ``;
4338
5199
  const orgs = session.orgs ?? [];
4339
5200
  const enabled = orgs.length > 0 ? await teams.enabled(orgs.map((org) => org.id)) : new Set();
4340
5201
  enabledOf.set(session, enabled);
@@ -4353,13 +5214,1661 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
4353
5214
  : html `<span class="muted">An owner can turn it on</span>`;
4354
5215
  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>`;
4355
5216
  });
4356
- const content = html `${noticeOf(notice, status)}${pageHead('Teams')}
5217
+ return html `${teamStore ? html `<h2>GitHub organizations</h2>` : html ``}
4357
5218
  <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>
4358
5219
  ${rows.length === 0
4359
5220
  ? 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.')
4360
5221
  : 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>
4361
5222
  <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>`}`;
4362
- return respond(status, layout('Teams', content, session, 'teams'));
5223
+ }
5224
+ /** People a team workspace may have, open invitations included: 3 on the Free plan where plans apply. */
5225
+ async function memberCapOf(tenantRef) {
5226
+ if (!plans)
5227
+ return null;
5228
+ const plan = await plans.plan(tenantRef);
5229
+ return plan === null || plan.plan === 'free' || plan.status === 'canceled' ? FREE_TEAM_MEMBERS : null;
5230
+ }
5231
+ /** The session moved into a workspace (its own when `team` is undefined), Live or Test as it was. */
5232
+ function moveTo(session, team, path, cookies = [], keepEmails = true) {
5233
+ const { org: _org, team: _team, em, ...rest } = session;
5234
+ const kept = keepEmails && em !== undefined ? { ...rest, em } : rest;
5235
+ const sealed = seal('slotlock-dashboard-session-v1', team === undefined ? kept : { ...kept, team });
5236
+ const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
5237
+ return redirect(303, `${root}${path}`, [setCookie(SESSION_COOKIE, sealed, left), ...cookies]);
5238
+ }
5239
+ const refusalPage = (session, refusal) => settingsPage(session, TEAM_REFUSALS[refusal].status, TEAM_REFUSALS[refusal].text, undefined, 'members');
5240
+ async function createTeam(session, form) {
5241
+ if (!teamStore)
5242
+ return nothingHere(session);
5243
+ if (session.idp === 'sso' || session.sc !== undefined)
5244
+ return teamsPage(session, 403, 'Sign in with GitHub or Google to create a team.');
5245
+ const name = (form.get('name') ?? '').trim();
5246
+ if (name.length === 0 || name.length > 80 || /\p{Cc}/u.test(name)) {
5247
+ return teamsPage(session, 400, TEAM_REFUSALS.invalid_name.text);
5248
+ }
5249
+ // A name goes into the invitations Slotlock sends: never a web or email address in it.
5250
+ if (/:\/\/|www\.|@|\b[a-z0-9-]+\.(?:[a-z]{2,}|xn--[a-z0-9-]+)\b/i.test(name)) {
5251
+ return teamsPage(session, 400, "A team's name can't hold a web or email address.");
5252
+ }
5253
+ const person = personOf(session);
5254
+ const cap = plans ? FREE_TEAMS_PER_PERSON : null;
5255
+ let made = { refusal: 'handle_taken' };
5256
+ // The handle follows the name; one taken gets a random end.
5257
+ for (let attempt = 0; attempt < 4 && 'refusal' in made && made.refusal === 'handle_taken'; attempt += 1) {
5258
+ made = await teamStore.create(person, name, teamHandleFrom(name, attempt), cap);
5259
+ }
5260
+ if ('refusal' in made)
5261
+ return teamsPage(session, TEAM_REFUSALS[made.refusal].status, TEAM_REFUSALS[made.refusal].text);
5262
+ await audit(session, { action: 'workspace.created', actorRole: 'owner', targetType: 'workspace', targetRef: made.tenantRef }, made.tenantRef);
5263
+ return moveTo(session, made.tenantRef.slice('team:'.length), '/settings/members');
5264
+ }
5265
+ /** A team's people as the others see them: their GitHub login or Google email, as last signed in. */
5266
+ const memberName = (member) => member.display ?? (member.person.startsWith('google:') ? 'A Google account' : 'A GitHub account');
5267
+ async function membersSection(session, status, notice, shown) {
5268
+ const tenantRef = teamTenantOf(session);
5269
+ if (!teamStore || tenantRef === undefined)
5270
+ return noticeOf(notice, status);
5271
+ const manage = can(session, 'members.manage');
5272
+ const me = personOf(session);
5273
+ const mine = roleOf(session);
5274
+ const grouping = options.resources.grouping;
5275
+ // Limiting people to locations and groups: by an Owner or Admin who is not limited themselves.
5276
+ const limits = manage && grouping !== undefined && (mine === 'owner' || mine === 'admin') && !scopes.has(session);
5277
+ const [members, invitations, cap, limited, places, requests, viaDomain] = await Promise.all([
5278
+ teamStore.members(tenantRef),
5279
+ manage ? teamStore.invitations(tenantRef) : Promise.resolve([]),
5280
+ memberCapOf(tenantRef),
5281
+ teamStore.scopes(tenantRef),
5282
+ limits && grouping ? Promise.all([grouping.locations(tenantRef), grouping.groups(tenantRef)]) : Promise.resolve(null),
5283
+ manage && domainStore ? domainStore.requests(tenantRef) : Promise.resolve([]),
5284
+ domainStore ? domainStore.domainMembers(tenantRef) : Promise.resolve(new Map()),
5285
+ ]);
5286
+ const open = invitations.filter((invitation) => invitation.expiresAt.getTime() > now());
5287
+ const seats = members.length + open.length;
5288
+ const full = cap !== null && seats >= cap;
5289
+ const team = currentTeam(session)?.name ?? 'this team';
5290
+ const owners = members.filter((member) => member.role === 'owner').length;
5291
+ const grantable = SLOTLOCK_WORKSPACE_ROLES.filter((role) => slotlockRoleMayGrant(mine, role));
5292
+ const token = csrfToken(session);
5293
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
5294
+ const roleOptions = (selected) => grantable.map((role) => html `<option value="${role}"${role === selected ? html ` selected` : html ``}>${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}</option>`);
5295
+ const invite = !manage
5296
+ ? html ``
5297
+ : full
5298
+ ? html `<p class="seats">${String(seats)} of ${String(cap)} people.${plans && can(session, 'usage.read') ? html ` <a href="${root}/plan">Upgrade</a>` : html ``}</p>`
5299
+ : html `<form class="invite" method="post" action="${root}/members/invite">${hidden()}<label for="invite-email">Email</label><input id="invite-email" name="email" type="email" maxlength="254" autocomplete="off" required${shown ? html `` : html ` autofocus`}><label class="visually-hidden" for="invite-role">Role</label><select id="invite-role" name="role">${roleOptions('member')}</select><button class="primary" type="submit">Invite</button></form>`;
5300
+ const link = shown
5301
+ ? html `<div class="set-card"><div class="row"><span class="row-label">Link for ${shown.to}</span><div class="row-value">${copyField('invite-link', shown.link)}</div></div></div><p class="fine">It works once, for that address, until ${formatDate(shown.expiresAt)}. It is not shown again.</p>`
5302
+ : html ``;
5303
+ const pendingRows = invitations.map((invitation) => {
5304
+ const expired = invitation.expiresAt.getTime() <= now();
5305
+ const to = invitation.email ?? `@${invitation.githubLogin ?? 'GitHub account'}`;
5306
+ return html `<tr><td data-label="Invited" class="strong">${to}</td><td data-label="Role">${SLOTLOCK_WORKSPACE_ROLE_NAMES[invitation.role]}</td><td data-label="By">${invitation.inviterDisplay}</td><td data-label="Expires">${expired ? html `<span class="pill revoked">Expired</span>` : formatDate(invitation.expiresAt)}</td><td class="actions">${slotlockRoleMayGrant(mine, invitation.role)
5307
+ ? html `<form class="inline" method="post" action="${root}/members/resend">${hidden()}<input type="hidden" name="id" value="${invitation.id}"><button type="submit">Send again<span class="visually-hidden"> to ${to}</span></button></form><form class="inline" method="post" action="${root}/members/revoke">${hidden()}<input type="hidden" name="id" value="${invitation.id}"><button class="danger" type="submit">Revoke<span class="visually-hidden"> the invitation to ${to}</span></button></form>`
5308
+ : html ``}</td></tr>`;
5309
+ });
5310
+ const memberRows = members.map((member, index) => {
5311
+ const name = memberName(member);
5312
+ const self = member.person === me;
5313
+ const changeable = manage && slotlockRoleMayGrant(mine, member.role);
5314
+ const role = changeable
5315
+ ? html `<form class="inline" method="post" action="${root}/members/role">${hidden()}<input type="hidden" name="person" value="${member.person}"><label class="visually-hidden" for="role-${String(index)}">Role for ${name}</label><select id="role-${String(index)}" name="role">${roleOptions(member.role)}</select><button type="submit">Save<span class="visually-hidden"> the role for ${name}</span></button></form>`
5316
+ : html `${SLOTLOCK_WORKSPACE_ROLE_NAMES[member.role]}`;
5317
+ let action = html ``;
5318
+ if (self) {
5319
+ action =
5320
+ member.role === 'owner' && owners <= 1
5321
+ ? html `<span class="muted">You’re the only owner. Make someone else an owner before leaving.</span>`
5322
+ : html `<details class="act danger"><summary>Leave</summary><div class="pop"><p>You lose access to ${team} at once. Keys you created keep working until an owner or admin revokes them.</p><form method="post" action="${root}/members/leave">${hidden()}<button class="danger" type="submit">Leave ${team}</button></form></div></details>`;
5323
+ }
5324
+ else if (changeable) {
5325
+ action = html `${member.role !== 'owner' && places ? limitForm(member.person, name, index, limited.get(member.person) ?? null, places[0], places[1], hidden) : html ``}<details class="act danger"><summary>Remove<span class="visually-hidden"> ${name}</span></summary><div class="pop"><p>${name} loses access to ${team} at their next request. Resources, bookings and history stay.</p><form method="post" action="${root}/members/remove">${hidden()}<input type="hidden" name="person" value="${member.person}"><fieldset><legend>Keys ${name} created</legend><label><input type="radio" name="keys" value="revoke" checked> Revoke them</label><label><input type="radio" name="keys" value="keep"> Keep them working</label></fieldset><button class="danger" type="submit">Remove ${name}</button></form></div></details>`;
5326
+ }
5327
+ const reach = member.role === 'owner' ? undefined : limited.get(member.person);
5328
+ const via = viaDomain.get(member.person);
5329
+ return html `<tr><td data-label="Member" class="strong">${name}${self ? html ` <span class="muted">(you)</span>` : html ``}${via ? html ` <span class="muted">via ${via.domain}${via.status === 'verified' ? '' : ', no longer verified'}</span>` : html ``}</td><td data-label="Role">${role}</td><td data-label="Reaches">${reach ? scopeWords(reach) : 'All'}</td><td data-label="Joined">${formatDate(member.joinedAt)}</td><td class="actions">${action}</td></tr>`;
5330
+ });
5331
+ // Requests to join through a verified domain: approved as Viewer, Member or Developer.
5332
+ const joinRoles = ['viewer', 'member', 'developer'].filter((role) => slotlockRoleMayGrant(mine, role));
5333
+ const requestRows = requests.map((request, index) => {
5334
+ const name = request.display ?? request.email;
5335
+ return html `<tr><td data-label="Asked" class="strong">${name}${request.display ? html ` <span class="muted">${request.email}</span>` : html ``} <span class="muted">${JOIN_EVIDENCE_WORDS[request.evidence]}</span></td><td data-label="Asked on">${formatDate(request.createdAt)}</td><td class="actions"><form class="inline" method="post" action="${root}/members/requests">${hidden()}<input type="hidden" name="id" value="${request.id}"><label class="visually-hidden" for="join-role-${String(index)}">Role for ${name}</label><select id="join-role-${String(index)}" name="role">${joinRoles.map((role) => html `<option value="${role}"${role === 'member' ? html ` selected` : html ``}>${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}</option>`)}</select><button class="primary" type="submit" name="decision" value="approve">Approve<span class="visually-hidden"> ${name}</span></button><button type="submit" name="decision" value="decline">Decline<span class="visually-hidden"> ${name}</span></button></form></td></tr>`;
5336
+ });
5337
+ return html `${noticeOf(notice, status)}<h2>Members · ${String(cap === null ? members.length : seats)}${cap === null ? '' : ` of ${String(cap)}`}</h2>${invite}${link}${requestRows.length > 0
5338
+ ? html `<h3>Asking to join</h3><div class="table"><table><thead><tr><th>Asked</th><th>Asked on</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${requestRows}</tbody></table></div>`
5339
+ : html ``}${pendingRows.length > 0
5340
+ ? html `<h3>Pending</h3><div class="table"><table><thead><tr><th>Invited</th><th>Role</th><th>By</th><th>Expires</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${pendingRows}</tbody></table></div>`
5341
+ : html ``}<div class="table"><table><thead><tr><th>Member</th><th>Role</th><th>Reaches</th><th>Joined</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${memberRows}</tbody></table></div>`;
5342
+ }
5343
+ /** What a limited member reaches, in words. */
5344
+ const scopeWords = (scope) => [
5345
+ scope.locations.length > 0 ? `Locations ${scope.locations.join(', ')}` : '',
5346
+ scope.groups.length > 0 ? `Groups ${scope.groups.join(', ')}` : '',
5347
+ ]
5348
+ .filter(Boolean)
5349
+ .join(' · ');
5350
+ /** Limit a member to locations and groups, or give them all of the workspace. */
5351
+ function limitForm(person, name, index, scope, locations, groups, hidden) {
5352
+ const id = `limit-${String(index)}`;
5353
+ const options_ = (entries, chosen) => entries.map((entry) => html `<option value="${entry.ref}"${chosen.includes(entry.ref) ? html ` selected` : html ``}>${entry.name} (${entry.ref})</option>`);
5354
+ const picker = (field, label, entries, chosen) => entries.length === 0
5355
+ ? html ``
5356
+ : html `<label for="${id}-${field}">${label}</label><select id="${id}-${field}" name="${field}" multiple size="${String(Math.min(6, entries.length))}">${options_(entries, chosen)}</select>`;
5357
+ if (locations.length === 0 && groups.length === 0 && scope === null)
5358
+ return html ``;
5359
+ return html `<details class="act"><summary>Limit<span class="visually-hidden"> ${name}</span></summary><div class="pop"><p>${name} reaches only the resources at these locations (and the locations in them) and in these groups: their bookings, holds, waitlist and approvals. Keys, webhooks, contacts, hours, usage and settings are not theirs.</p><form method="post" action="${root}/members/scope">${hidden()}<input type="hidden" name="person" value="${person}">${picker('locations', 'Locations', locations, scope?.locations ?? [])}${picker('groups', 'Groups', groups, scope?.groups ?? [])}<fieldset><legend>Keys ${name} created</legend><label><input type="radio" name="keys" value="revoke" checked> Revoke them: they reach all of it</label><label><input type="radio" name="keys" value="keep"> Keep them working</label></fieldset><button type="submit" name="access" value="limited">Limit ${name} to these</button>${scope === null ? html `` : html `<button type="submit" name="access" value="all">Give ${name} all of the workspace</button>`}</form></div></details>`;
5360
+ }
5361
+ /** Who did something, as the audit log names them: you, a member by their display, else their account. */
5362
+ function actorName(event, session, names) {
5363
+ if (event.actorType === 'api_key')
5364
+ return `API key ${event.actorRef.replace(/^api_key:/, '').slice(0, 8)}`;
5365
+ if (event.actorType === 'assistant')
5366
+ return 'The assistant';
5367
+ if (event.actorType !== 'person')
5368
+ return 'Slotlock';
5369
+ if (event.actorRef === personOf(session))
5370
+ return 'You';
5371
+ return (names.get(event.actorRef) ??
5372
+ (event.actorRef.startsWith('github:') ? `GitHub user ${event.actorRef.slice(7)}` : event.actorRef.startsWith('google:') ? 'A Google account' : event.actorRef));
5373
+ }
5374
+ /** The ways Privacy finds a person, as the form names them. */
5375
+ const PERSON_KINDS = Object.freeze({
5376
+ email: 'Email address',
5377
+ holder: 'Holder reference',
5378
+ contact: 'Contact reference',
5379
+ reference: 'Booking reference',
5380
+ });
5381
+ /** A person named on a Privacy form, checked: one kind and a value, or null. */
5382
+ function personOf_(form) {
5383
+ const kind = form.get('kind') ?? '';
5384
+ const value = (form.get('value') ?? '').trim();
5385
+ if (!Object.hasOwn(PERSON_KINDS, kind) || value.length === 0 || Buffer.byteLength(value) > 500)
5386
+ return null;
5387
+ const named = kind;
5388
+ return { kind: named, value, subject: { [named]: value } };
5389
+ }
5390
+ /**
5391
+ * Privacy (RFC 0004 D3): an export of everything the workspace keeps, to download for a day; and
5392
+ * one person found by email, holder, contact or booking reference, what is kept about them
5393
+ * downloaded, or them erased. Owners and Admins, signed in within 15 minutes.
5394
+ */
5395
+ async function privacySection(session, status, notice, found) {
5396
+ const privacy = options.resources.privacy;
5397
+ if (!privacy)
5398
+ return noticeOf(notice, status);
5399
+ const exports = await privacy.listExports(tenantOf(session));
5400
+ const hidden = html `<input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}">`;
5401
+ const rows = exports.map((made) => html `<tr><td data-label="Asked for">${formatDate(made.createdAt)}</td><td data-label="State">${made.status === 'ready'
5402
+ ? html `<span class="pill active">Ready</span>`
5403
+ : made.status === 'pending'
5404
+ ? html `<span class="pill">Being made</span>`
5405
+ : html `<span class="pill revoked">Failed</span>${made.error === 'export_too_large' ? html ` <span class="muted">too large: ask the operator</span>` : html ``}`}</td><td data-label="Rows">${made.status === 'ready' ? String(made.rows) : '—'}</td><td data-label="Deleted">${formatDate(made.expiresAt)}</td><td class="actions">${made.status === 'ready' ? html `<a class="button" href="${root}/settings/privacy/download?id=${made.id}">Download</a> ` : html ``}<form method="post" action="${root}/settings/privacy/export/delete">${hidden}<input type="hidden" name="id" value="${made.id}"><button type="submit">Delete</button></form></td></tr>`);
5406
+ const exportCard = html `<h2>Everything</h2><p>A copy of everything this workspace keeps, every table read at one moment, as JSON lines: one <code>{"table", "row"}</code> a line. Secrets stay out. It is kept for a day, then deleted.</p><form method="post" action="${root}/settings/privacy/export">${hidden}<button type="submit" class="primary">Export the workspace</button></form>${rows.length > 0
5407
+ ? html `<div class="table"><table><thead><tr><th>Asked for</th><th>State</th><th>Rows</th><th>Deleted</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`
5408
+ : html ``}`;
5409
+ const kinds = Object.entries(PERSON_KINDS).map(([kind, label]) => html `<option value="${kind}"${found?.kind === kind ? html ` selected` : html ``}>${label}</option>`);
5410
+ const finder = html `<h2>One person</h2><p>For a person who asks what you keep about them, or to be erased. Find them by one thing you know.</p><form class="person-find" method="post" action="${root}/settings/privacy/person">${hidden}<label for="person-kind">By</label><select id="person-kind" name="kind">${kinds}</select><label for="person-value" class="visually-hidden">Value</label><input id="person-value" name="value" maxlength="320" required value="${found?.value ?? ''}"><button type="submit">Find</button></form>`;
5411
+ let person = html ``;
5412
+ if (found) {
5413
+ const counted = Object.entries(found.records).filter(([, rows_]) => rows_.length > 0);
5414
+ const keep = html `<input type="hidden" name="kind" value="${found.kind}"><input type="hidden" name="value" value="${found.value}">`;
5415
+ person =
5416
+ counted.length === 0
5417
+ ? html `<p class="empty">Nothing kept names them.</p>`
5418
+ : html `<div class="set-card">${counted.map(([table, rows_]) => html `<div class="row"><span class="row-label">${table.replaceAll('_', ' ')}</span><div class="row-value">${String(rows_.length)}${found.truncated && rows_.length >= 1_000 ? '+' : ''}</div></div>`)}</div><div class="actions"><form method="post" action="${root}/settings/privacy/person/export">${hidden}${keep}<button type="submit">Download what is kept</button></form></div><form method="post" action="${root}/settings/privacy/person/erase" class="danger-zone">${hidden}${keep}<p>Erasing deletes their contacts, attendee and waitlist entries, takes their email out of events and approvals, and replaces their references with a name that is no one's, everywhere, history included. Their bookings stay, as time taken. This cannot be undone.</p><label class="choice"><input type="checkbox" name="confirm" value="yes" required> I understand</label><button type="submit" class="danger">Erase this person</button></form>`;
5419
+ }
5420
+ return html `${noticeOf(notice, status)}${exportCard}${finder}${person}`;
5421
+ }
5422
+ async function askPrivacyExport(session) {
5423
+ const privacy = options.resources.privacy;
5424
+ if (!privacy)
5425
+ return nothingHere(session);
5426
+ const tenantRef = tenantOf(session);
5427
+ try {
5428
+ const made = await privacy.createExport(tenantRef, personOf(session));
5429
+ await audit(session, { action: 'export.created', targetType: 'export', targetRef: made.id });
5430
+ return settingsPage(session, 200, 'Asked for. It is made in a moment: reload this page to download it.', undefined, 'privacy');
5431
+ }
5432
+ catch (error) {
5433
+ const code = error?.code;
5434
+ if (code === 'export_in_progress') {
5435
+ return settingsPage(session, 409, 'One is being made already: wait for it.', undefined, 'privacy');
5436
+ }
5437
+ if (code === 'export_limit_reached') {
5438
+ return settingsPage(session, 409, 'Five exports are kept already: delete one first.', undefined, 'privacy');
5439
+ }
5440
+ throw error;
5441
+ }
5442
+ }
5443
+ async function deletePrivacyExport(session, form) {
5444
+ const privacy = options.resources.privacy;
5445
+ if (!privacy)
5446
+ return nothingHere(session);
5447
+ const id = form.get('id') ?? '';
5448
+ if (!UUID_PATTERN.test(id) || !(await privacy.deleteExport(tenantOf(session), id))) {
5449
+ return settingsPage(session, 404, 'That export is not here.', undefined, 'privacy');
5450
+ }
5451
+ return settingsPage(session, 200, 'Deleted.', undefined, 'privacy');
5452
+ }
5453
+ /**
5454
+ * A ready export as one file of JSON lines, `{"table", "row"}` each, read a part at a time. Only
5455
+ * with a sign-in in the last 15 minutes, as every Privacy action.
5456
+ */
5457
+ async function downloadPrivacyExport(session, query) {
5458
+ const privacy = options.resources.privacy;
5459
+ if (!privacy)
5460
+ return nothingHere(session);
5461
+ if (!freshlySignedIn(session))
5462
+ return confirmPage(session, 'privacy.manage', '/settings/privacy');
5463
+ const id = query.get('id') ?? '';
5464
+ const tenantRef = tenantOf(session);
5465
+ const first = UUID_PATTERN.test(id) ? await privacy.exportPart(tenantRef, id, 1) : null;
5466
+ if (!first || (!first.ok && first.code !== 'export_part_not_found')) {
5467
+ return settingsPage(session, first && !first.ok && first.code === 'export_not_ready' ? 409 : 404, 'That export is not ready, or not here.', undefined, 'privacy');
5468
+ }
5469
+ await audit(session, { action: 'export.downloaded', targetType: 'export', targetRef: id });
5470
+ const encoder = new TextEncoder();
5471
+ let next = first;
5472
+ let part = 1;
5473
+ const body = new ReadableStream({
5474
+ async pull(controller) {
5475
+ const read = next ?? (await privacy.exportPart(tenantRef, id, part));
5476
+ next = null;
5477
+ if (!read.ok) {
5478
+ controller.close();
5479
+ return;
5480
+ }
5481
+ controller.enqueue(encoder.encode(read.lines.map((line) => `{"table":${JSON.stringify(read.table)},"row":${line}}\n`).join('')));
5482
+ part += 1;
5483
+ },
5484
+ });
5485
+ return new Response(body, {
5486
+ status: 200,
5487
+ headers: {
5488
+ 'Content-Type': 'application/x-ndjson; charset=utf-8',
5489
+ 'Content-Disposition': `attachment; filename="slotlock-export-${new Date(now()).toISOString().slice(0, 10)}.ndjson"`,
5490
+ 'Cache-Control': 'no-store',
5491
+ 'X-Content-Type-Options': 'nosniff',
5492
+ 'Content-Security-Policy': "default-src 'none'",
5493
+ },
5494
+ });
5495
+ }
5496
+ async function findPerson(session, form) {
5497
+ const privacy = options.resources.privacy;
5498
+ if (!privacy)
5499
+ return nothingHere(session);
5500
+ const named = personOf_(form);
5501
+ if (!named)
5502
+ return settingsPage(session, 400, 'Choose how to find them, and what you know.', undefined, 'privacy');
5503
+ try {
5504
+ const exported = await privacy.exportSubject(tenantOf(session), named.subject);
5505
+ return settingsPage(session, 200, undefined, undefined, 'privacy', undefined, undefined, {
5506
+ kind: named.kind,
5507
+ value: named.value,
5508
+ records: exported.records,
5509
+ truncated: exported.truncated,
5510
+ });
5511
+ }
5512
+ catch (error) {
5513
+ if (error?.code === 'invalid_subject') {
5514
+ return settingsPage(session, 400, 'That is not one: an email address, or a reference of up to 500 bytes.', undefined, 'privacy');
5515
+ }
5516
+ throw error;
5517
+ }
5518
+ }
5519
+ /** What is kept about one person, as a JSON file: posted, so the person is never in an address. */
5520
+ async function exportPerson(session, form) {
5521
+ const privacy = options.resources.privacy;
5522
+ if (!privacy)
5523
+ return nothingHere(session);
5524
+ const named = personOf_(form);
5525
+ if (!named)
5526
+ return settingsPage(session, 400, 'Choose how to find them, and what you know.', undefined, 'privacy');
5527
+ const exported = await privacy.exportSubject(tenantOf(session), named.subject);
5528
+ await audit(session, { action: 'subject.exported', targetType: 'subject', metadata: { by: named.kind } });
5529
+ return new Response(JSON.stringify(exported, null, 2), {
5530
+ status: 200,
5531
+ headers: {
5532
+ 'Content-Type': 'application/json; charset=utf-8',
5533
+ 'Content-Disposition': `attachment; filename="slotlock-person-${new Date(now()).toISOString().slice(0, 10)}.json"`,
5534
+ 'Cache-Control': 'no-store',
5535
+ 'X-Content-Type-Options': 'nosniff',
5536
+ 'Content-Security-Policy': "default-src 'none'",
5537
+ },
5538
+ });
5539
+ }
5540
+ async function erasePerson(session, form) {
5541
+ const privacy = options.resources.privacy;
5542
+ if (!privacy)
5543
+ return nothingHere(session);
5544
+ const named = personOf_(form);
5545
+ if (!named)
5546
+ return settingsPage(session, 400, 'Choose how to find them, and what you know.', undefined, 'privacy');
5547
+ if (form.get('confirm') !== 'yes')
5548
+ return settingsPage(session, 400, 'Tick that you understand first.', undefined, 'privacy');
5549
+ const { erased } = await privacy.eraseSubject(tenantOf(session), named.subject, personOf(session));
5550
+ const changed = Object.entries(erased).filter(([, rows]) => rows > 0);
5551
+ // Never the person: how they were named, and what went.
5552
+ await audit(session, { action: 'subject.erased', targetType: 'subject', metadata: { by: named.kind, ...Object.fromEntries(changed) } });
5553
+ const total = changed.reduce((sum, [, rows]) => sum + rows, 0);
5554
+ return settingsPage(session, 200, total === 0 ? 'Nothing kept named them.' : `Erased: ${String(total)} row${total === 1 ? '' : 's'} deleted or changed. Their bookings stay, as time taken.`, undefined, 'privacy');
5555
+ }
5556
+ /** A workspace's audit log, newest first, 50 a page: who did what, refused or done, from where. */
5557
+ async function auditSection(session, status, notice, query) {
5558
+ const log = options.resources.audit;
5559
+ if (!log)
5560
+ return noticeOf(notice, status);
5561
+ const tenantRef = tenantOf(session);
5562
+ const wanted = query.get('action') ?? '';
5563
+ const action = AUDIT_ACTION_NAMES.has(wanted) ? wanted : undefined;
5564
+ const cursor = /^(\d{1,15})\.([0-9a-f-]{36})$/.exec(query.get('before') ?? '');
5565
+ const before = cursor && UUID_PATTERN.test(cursor[2])
5566
+ ? { occurredAt: new Date(Number(cursor[1])), id: cursor[2].toLowerCase() }
5567
+ : undefined;
5568
+ const team = teamTenantOf(session);
5569
+ const [events, members] = await Promise.all([
5570
+ log.list(tenantRef, { limit: AUDIT_PAGE, ...(before ? { before } : {}), ...(action ? { action } : {}) }),
5571
+ teamStore && team !== undefined ? teamStore.members(team) : Promise.resolve([]),
5572
+ ]);
5573
+ const names = new Map(members.map((member) => [member.person, memberName(member)]));
5574
+ const options_ = AUDIT_ACTION_GROUPS.map(([group, actions]) => html `<optgroup label="${group}">${actions.map((name) => html `<option value="${name}"${name === action ? html ` selected` : html ``}>${AUDIT_ACTION_WORDS[name] ?? name}</option>`)}</optgroup>`);
5575
+ const filter = html `<form class="audit-filter" method="get" action="${root}/settings/audit"><label for="audit-action">Action</label><select id="audit-action" name="action"><option value="">Everything</option>${options_}</select><button type="submit">Show</button></form>`;
5576
+ const rows = events.map((event) => {
5577
+ const details = [
5578
+ ...(event.targetRef ? [['Target', `${event.targetType ?? ''} ${event.targetRef}`.trim()]] : []),
5579
+ ...(event.reason ? [['Reason', event.reason]] : []),
5580
+ ...Object.entries(event.metadata).map(([key, value]) => [key, String(value)]),
5581
+ ...(event.ipAddress ? [['Address', event.ipAddress]] : []),
5582
+ ...(event.userAgent ? [['Browser', event.userAgent]] : []),
5583
+ ];
5584
+ return html `<tr><td data-label="When">${formatDate(event.occurredAt)}</td><td data-label="Who" class="strong">${actorName(event, session, names)}${event.actorRole ? html ` <span class="muted">(${SLOTLOCK_WORKSPACE_ROLE_NAMES[event.actorRole]})</span>` : html ``}</td><td data-label="What">${AUDIT_ACTION_WORDS[event.action] ?? event.action}${event.outcome === 'denied' ? html ` <span class="pill revoked">Refused</span>` : html ``}</td><td class="actions">${details.length > 0
5585
+ ? html `<details class="act"><summary>Details</summary><div class="pop"><dl class="audit-details">${details.map(([label, value]) => html `<dt>${label}</dt><dd>${value}</dd>`)}</dl></div></details>`
5586
+ : html ``}</td></tr>`;
5587
+ });
5588
+ const last = events[events.length - 1];
5589
+ const older = last && events.length === AUDIT_PAGE
5590
+ ? `${root}/settings/audit?${new URLSearchParams({ ...(action ? { action } : {}), before: `${String(last.occurredAt.getTime())}.${last.id}` }).toString()}`
5591
+ : null;
5592
+ // The chain, checked on asking: every event kept since the oldest still links to the one before.
5593
+ const verified = query.get('verify') === '1' && log.verify ? await log.verify(tenantRef) : null;
5594
+ const chain = verified
5595
+ ? verified.brokenAt === null
5596
+ ? html `<p class="notice done" role="status">The log is unbroken: ${String(verified.checked)} event${verified.checked === 1 ? '' : 's'} checked.</p>`
5597
+ : html `<p class="notice" role="alert">The log was changed outside Slotlock at event ${String(verified.brokenAt)}, after ${String(verified.checked)} that check out.</p>`
5598
+ : html ``;
5599
+ const exportQuery = action ? `&action=${action}` : '';
5600
+ const tools = html `<p class="audit-tools">${log.verify ? html `<a href="${root}/settings/audit?verify=1${exportQuery}">Check the log</a>` : html ``} <a href="${root}/settings/audit/export?format=csv${exportQuery}">Export CSV</a> <a href="${root}/settings/audit/export?format=json${exportQuery}">Export JSON</a></p>`;
5601
+ return html `${noticeOf(notice, status)}${chain}${filter}${tools}${rows.length === 0
5602
+ ? html `<p class="empty">${before ? 'Nothing older.' : 'Nothing recorded yet.'}</p>`
5603
+ : html `<div class="table"><table><thead><tr><th>When</th><th>Who</th><th>What</th><th><span class="visually-hidden">Details</span></th></tr></thead><tbody>${rows}</tbody></table></div>`}<p class="pager">${before ? html `<a href="${root}/settings/audit${action ? `?action=${action}` : ''}">Newest</a>` : html ``}${older ? html ` <a href="${older}">Older</a>` : html ``}</p>`;
5604
+ }
5605
+ /**
5606
+ * A workspace's audit log as a file, filtered as the page was: up to 10,000 events, newest first,
5607
+ * with each event's place in the chain and its hash. On the Team plan where plans apply.
5608
+ */
5609
+ async function exportAudit(session, query) {
5610
+ const log = options.resources.audit;
5611
+ if (!log)
5612
+ return nothingHere(session);
5613
+ const tenantRef = tenantOf(session);
5614
+ if (plans) {
5615
+ const plan = await plans.plan(tenantRef.replace(/~test$/, ''));
5616
+ if (plan?.plan !== 'team' || plan.status === 'canceled') {
5617
+ return settingsPage(session, 403, 'Exports come with the Team plan. Upgrade on Usage.', undefined, 'audit', undefined, query);
5618
+ }
5619
+ }
5620
+ const format = query.get('format') === 'json' ? 'json' : 'csv';
5621
+ const wanted = query.get('action') ?? '';
5622
+ const action = AUDIT_ACTION_NAMES.has(wanted) ? wanted : undefined;
5623
+ const events = [];
5624
+ let before;
5625
+ while (events.length < AUDIT_EXPORT_MOST) {
5626
+ const page = await log.list(tenantRef, { limit: 200, ...(before ? { before } : {}), ...(action ? { action } : {}) });
5627
+ events.push(...page);
5628
+ const last = page[page.length - 1];
5629
+ if (!last || page.length < 200)
5630
+ break;
5631
+ before = { occurredAt: last.occurredAt, id: last.id };
5632
+ }
5633
+ await audit(session, { action: 'audit.exported', metadata: { format, events: events.length, ...(action ? { filter: action } : {}) } });
5634
+ const rows = events.slice(0, AUDIT_EXPORT_MOST).map((event) => ({
5635
+ occurred_at: event.occurredAt.toISOString(),
5636
+ action: event.action,
5637
+ outcome: event.outcome,
5638
+ actor_type: event.actorType,
5639
+ actor_ref: event.actorRef,
5640
+ actor_role: event.actorRole ?? '',
5641
+ target_type: event.targetType ?? '',
5642
+ target_ref: event.targetRef ?? '',
5643
+ reason: event.reason ?? '',
5644
+ ip_address: event.ipAddress ?? '',
5645
+ user_agent: event.userAgent ?? '',
5646
+ metadata: JSON.stringify(event.metadata),
5647
+ chain_index: event.chainIndex === null ? '' : String(event.chainIndex),
5648
+ hash: event.hash ?? '',
5649
+ }));
5650
+ const day = new Date(now()).toISOString().slice(0, 10);
5651
+ const body = format === 'json'
5652
+ ? JSON.stringify(rows.map((row) => ({ ...row, metadata: JSON.parse(row.metadata) })), null, 2)
5653
+ : [
5654
+ Object.keys(rows[0] ?? AUDIT_EXPORT_EMPTY).join(','),
5655
+ ...rows.map((row) => Object.values(row).map(csvCell).join(',')),
5656
+ ].join('\r\n');
5657
+ return new Response(body, {
5658
+ status: 200,
5659
+ headers: {
5660
+ 'Content-Type': format === 'json' ? 'application/json; charset=utf-8' : 'text/csv; charset=utf-8',
5661
+ 'Content-Disposition': `attachment; filename="slotlock-audit-${day}.${format}"`,
5662
+ 'Cache-Control': 'no-store',
5663
+ 'X-Content-Type-Options': 'nosniff',
5664
+ 'Content-Security-Policy': "default-src 'none'",
5665
+ },
5666
+ });
5667
+ }
5668
+ /** A team's name and handle, and deleting it: what goes with it, the handle typed to confirm. */
5669
+ async function teamSection(session, status, notice) {
5670
+ const tenantRef = teamTenantOf(session);
5671
+ if (!teamStore || tenantRef === undefined)
5672
+ return noticeOf(notice, status);
5673
+ const [info, deleting, members, resources, keys, webhooks] = await Promise.all([
5674
+ teamStore.info(tenantRef),
5675
+ teamStore.deleteAfter(tenantRef),
5676
+ teamStore.members(tenantRef),
5677
+ options.resources.count ? options.resources.count(tenantRef) : options.resources.list(tenantRef).then((list) => list.length),
5678
+ options.keys.list({ tenantRef }),
5679
+ options.resources.webhooks ? options.resources.webhooks.list(tenantRef) : Promise.resolve([]),
5680
+ ]);
5681
+ if (!info)
5682
+ return noticeOf(notice, status);
5683
+ const hidden = html `<input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}">`;
5684
+ const live = keys.filter((key) => key.revokedAt === null && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
5685
+ const card = html `<div class="set-card"><div class="row"><span class="row-label">Name</span><div class="row-value">${info.name}</div></div><div class="row"><span class="row-label">Handle</span><div class="row-value">@${info.handle}</div></div></div>`;
5686
+ const danger = deleting
5687
+ ? html `<h2>Being deleted</h2><p>${info.name} and everything in it go on ${formatDate(deleting)}.</p><form method="post" action="${root}/teams/restore">${hidden}<button class="primary" type="submit">Keep ${info.name}</button></form>`
5688
+ : html `<h2>Delete team</h2><p>Its keys stop at once, and seven days later it goes with everything in it: ${String(members.length)} member${members.length === 1 ? '' : 's'}, ${String(resources)} resource${resources === 1 ? '' : 's'} with their bookings and history, ${String(live)} key${live === 1 ? '' : 's'} and ${String(webhooks.length)} webhook${webhooks.length === 1 ? '' : 's'}. Until then any owner can keep it.</p><form class="create-team" method="post" action="${root}/teams/delete">${hidden}<label for="confirm-handle">Type ${info.handle} to confirm</label><input id="confirm-handle" name="confirm" autocomplete="off" required><button class="danger" type="submit">Delete ${info.name}</button></form>`;
5689
+ return html `${noticeOf(notice, status)}${card}${danger}`;
5690
+ }
5691
+ async function deleteTeam(session, form) {
5692
+ const tenantRef = teamTenantOf(session);
5693
+ if (!teamStore || tenantRef === undefined)
5694
+ return nothingHere(session);
5695
+ const refusal = await teamStore.scheduleDeletion(tenantRef, personOf(session), (form.get('confirm') ?? '').trim());
5696
+ if (refusal === 'confirm')
5697
+ return settingsPage(session, 400, "Type the team's handle to confirm.", undefined, 'team');
5698
+ if (refusal === 'subscription_live') {
5699
+ return settingsPage(session, 409, 'Cancel its Team plan on Usage first: deleting the team would not stop the payments.', undefined, 'team');
5700
+ }
5701
+ if (refusal)
5702
+ return settingsPage(session, TEAM_REFUSALS[refusal].status, TEAM_REFUSALS[refusal].text, undefined, 'team');
5703
+ await audit(session, { action: 'workspace.deletion_scheduled', targetType: 'workspace', targetRef: tenantRef }, tenantRef);
5704
+ return redirect(303, `${root}/settings/team`);
5705
+ }
5706
+ async function restoreTeam(session) {
5707
+ const tenantRef = teamTenantOf(session);
5708
+ if (!teamStore || tenantRef === undefined)
5709
+ return nothingHere(session);
5710
+ const refusal = await teamStore.cancelDeletion(tenantRef, personOf(session));
5711
+ if (refusal)
5712
+ return settingsPage(session, TEAM_REFUSALS[refusal].status, TEAM_REFUSALS[refusal].text, undefined, 'team');
5713
+ await audit(session, { action: 'workspace.deletion_cancelled', targetType: 'workspace', targetRef: tenantRef }, tenantRef);
5714
+ return redirect(303, `${root}/settings/team`);
5715
+ }
5716
+ /** What a refused change to a team's domains, or a request to join, says, and its status. */
5717
+ const DOMAIN_REFUSALS = Object.freeze({
5718
+ invalid_domain: { status: 400, text: 'Enter a domain your organization owns, such as acme.com.' },
5719
+ public_suffix: { status: 400, text: 'Nobody owns all of that domain. Enter your own domain under it.' },
5720
+ consumer_domain: { status: 400, text: 'Anyone can get an address at that domain. Enter your organization’s own.' },
5721
+ not_found: { status: 404, text: 'That is no longer here.' },
5722
+ not_allowed: { status: 403, text: 'Only an owner can change the team’s domains.' },
5723
+ already_claimed: { status: 409, text: 'That domain is listed already.' },
5724
+ domain_taken: { status: 409, text: 'Another team holds that domain.' },
5725
+ domain_limit: { status: 403, text: `A team has up to ${String(SLOTLOCK_WORKSPACE_DOMAIN_LIMIT)} domains.` },
5726
+ too_soon: { status: 429, text: 'Checked less than a minute ago. Try again shortly.' },
5727
+ expired: { status: 409, text: 'That record expired after seven days. Add the domain again for a new one.' },
5728
+ restricted: { status: 409, text: 'Invitations go only to verified domains: allow any address before removing the last one.' },
5729
+ no_verified_domain: { status: 409, text: 'Verify a domain first.' },
5730
+ already_member: { status: 409, text: 'You are a member already.' },
5731
+ pending: { status: 409, text: 'You asked already. An owner or admin decides.' },
5732
+ request_limit: { status: 429, text: 'You asked to join 20 teams in the last day. Try again tomorrow.' },
5733
+ member_cap: TEAM_REFUSALS.member_cap,
5734
+ role_not_allowed: { status: 400, text: 'Someone joining through a domain joins as Viewer, Member or Developer.' },
5735
+ domain_not_verified: { status: 409, text: 'That domain is no longer verified, so the request cannot be approved.' },
5736
+ });
5737
+ /** How a request's address was verified, as the people deciding read it. */
5738
+ const JOIN_EVIDENCE_WORDS = Object.freeze({
5739
+ google_workspace: 'verified by Google Workspace',
5740
+ google: 'verified once by Google, not checked since',
5741
+ github: 'verified once by GitHub, not checked since',
5742
+ });
5743
+ /** What a domain lets the people whose verified address is at it do, as the Identity page offers it. */
5744
+ const JOIN_POLICY_WORDS = Object.freeze({
5745
+ request: 'Ask to join',
5746
+ auto: 'Join as Viewer',
5747
+ off: 'Invitations only',
5748
+ });
5749
+ const identityPage = (session, status, notice) => settingsPage(session, status, notice, undefined, 'identity');
5750
+ const domainRefused = (session, refusal) => identityPage(session, DOMAIN_REFUSALS[refusal].status, DOMAIN_REFUSALS[refusal].text);
5751
+ /** A team's domains: each with its record until verified, who may join through it, and invitations. */
5752
+ async function identitySection(session, status, notice) {
5753
+ const tenantRef = teamTenantOf(session);
5754
+ if (!domainStore || tenantRef === undefined)
5755
+ return noticeOf(notice, status);
5756
+ const [listed, restricted] = await Promise.all([domainStore.list(tenantRef), domainStore.invitesRestricted(tenantRef)]);
5757
+ const token = csrfToken(session);
5758
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
5759
+ const cards = listed.map((domain, index) => {
5760
+ const name = domain.unicode === domain.ascii ? html `${domain.unicode}` : html `${domain.unicode} <span class="muted">${domain.ascii}</span>`;
5761
+ const [pill, word] = domain.expired
5762
+ ? ['expired', 'Expired']
5763
+ : domain.status === 'verified'
5764
+ ? ['active', 'Verified']
5765
+ : domain.status === 'lapsing'
5766
+ ? ['revoked', 'Record missing']
5767
+ : ['held', 'Pending'];
5768
+ const record = slotlockDomainRecord(domain.ascii, domain.token);
5769
+ const id = html `<input type="hidden" name="id" value="${domain.id}">`;
5770
+ let rows;
5771
+ if (domain.status === 'verified') {
5772
+ rows = html `<div class="row"><span class="row-label">Who may join</span><div class="row-value"><form class="inline" method="post" action="${root}/identity/domains/policy">${hidden()}${id}<label class="visually-hidden" for="policy-${String(index)}">Who may join through ${domain.unicode}</label><select id="policy-${String(index)}" name="policy">${['request', 'auto', 'off'].map((policy) => html `<option value="${policy}"${policy === domain.joinPolicy ? html ` selected` : html ``}>${JOIN_POLICY_WORDS[policy]}</option>`)}</select><button type="submit">Save<span class="visually-hidden"> who may join through ${domain.unicode}</span></button></form></div></div>`;
5773
+ }
5774
+ else if (domain.expired) {
5775
+ rows = html `<div class="row"><span class="row-label">Record</span><div class="row-value"><form class="inline" method="post" action="${root}/identity/domains">${hidden()}<input type="hidden" name="domain" value="${domain.ascii}"><button type="submit">Get a new record<span class="visually-hidden"> for ${domain.unicode}</span></button></form></div></div>`;
5776
+ }
5777
+ else {
5778
+ rows = html `${domain.status === 'lapsing' && domain.lapseAfter
5779
+ ? html `<div class="row"><span class="row-label">Lapses</span><div class="row-value">${formatDate(domain.lapseAfter)}, unless this new record is published</div></div>`
5780
+ : html ``}<div class="row"><span class="row-label">TXT name</span><div class="row-value">${copyField(`record-name-${String(index)}`, record.name)}</div></div><div class="row"><span class="row-label">TXT value</span><div class="row-value">${copyField(`record-value-${String(index)}`, record.value)}</div></div><div class="row"><span class="row-label">Check</span><div class="row-value"><form class="inline" method="post" action="${root}/identity/domains/verify">${hidden()}${id}<button class="primary" type="submit">Verify now<span class="visually-hidden"> ${domain.unicode}</span></button></form></div></div>`;
5781
+ }
5782
+ const remove = html `<details class="act danger"><summary>Remove<span class="visually-hidden"> ${domain.unicode}</span></summary><div class="pop"><p>${domain.status === 'pending' ? 'The claim is withdrawn.' : 'Nobody new joins through it. Members who joined through it stay.'}</p><form method="post" action="${root}/identity/domains/remove">${hidden()}${id}<button class="danger" type="submit">Remove ${domain.unicode}</button></form></div></details>`;
5783
+ return html `<div class="set-card"><div class="row"><span class="row-label">Domain</span><div class="row-value"><span class="strong">${name}</span> <span class="pill ${pill}">${word}</span> ${remove}</div></div>${rows}</div>`;
5784
+ });
5785
+ const add = listed.length >= SLOTLOCK_WORKSPACE_DOMAIN_LIMIT
5786
+ ? html ``
5787
+ : html `<form class="invite" method="post" action="${root}/identity/domains">${hidden()}<label for="domain">Domain</label><input id="domain" name="domain" maxlength="253" autocomplete="off" spellcheck="false" placeholder="acme.com" required><button class="primary" type="submit">Add domain</button></form>`;
5788
+ const verified = listed.some((domain) => domain.status === 'verified');
5789
+ const invites = html `<h2>Invitations</h2><div class="set-card"><div class="row"><span class="row-label">Go to</span><div class="row-value">${restricted ? 'Addresses at verified domains' : 'Any address'} ${restricted || verified
5790
+ ? html `<form class="inline" method="post" action="${root}/identity/invites">${hidden()}<input type="hidden" name="on" value="${restricted ? '0' : '1'}"><button type="submit">${restricted ? 'Allow any address' : 'Only verified domains'}</button></form>`
5791
+ : html ``}</div></div></div>`;
5792
+ return html `${noticeOf(notice, status)}<h2>Domains</h2>${add}${cards}${invites}${await ssoSection(session, tenantRef)}${await scimSection(session, tenantRef)}`;
5793
+ }
5794
+ async function claimDomain(session, form) {
5795
+ const tenantRef = teamTenantOf(session);
5796
+ if (!domainStore || tenantRef === undefined)
5797
+ return nothingHere(session);
5798
+ const claimed = await domainStore.claim(tenantRef, personOf(session), (form.get('domain') ?? '').slice(0, 300));
5799
+ if ('refusal' in claimed)
5800
+ return domainRefused(session, claimed.refusal);
5801
+ const domain = (await domainStore.list(tenantRef)).find((listed) => listed.id === claimed.id);
5802
+ await audit(session, { action: 'domain.claimed', targetType: 'domain', targetRef: claimed.id, metadata: { domain: domain?.ascii ?? '' } }, tenantRef);
5803
+ return redirect(303, `${root}/settings/identity`);
5804
+ }
5805
+ /** What an Owner's "Verify now" found, as the page says it. */
5806
+ const VERIFY_ANSWERS = Object.freeze({
5807
+ verified: { status: 200, text: (domain) => `${domain} is verified.` },
5808
+ restored: { status: 200, text: (domain) => `${domain} is verified again.` },
5809
+ seen: { status: 200, text: (domain) => `${domain} is still verified.` },
5810
+ absent: { status: 409, text: () => 'The record isn’t there yet. Slotlock also checks every hour for three days.' },
5811
+ error: { status: 503, text: () => 'DNS didn’t answer. Try again shortly.' },
5812
+ domain_taken: { status: 409, text: () => 'Another team verified that domain first.' },
5813
+ });
5814
+ async function verifyDomain(session, form) {
5815
+ const tenantRef = teamTenantOf(session);
5816
+ if (!domainStore || tenantRef === undefined)
5817
+ return nothingHere(session);
5818
+ const found = await domainStore.verifyNow(tenantRef, personOf(session), form.get('id') ?? '', domainResolvers);
5819
+ const event = slotlockDomainCheckEvent(found, false);
5820
+ if (event)
5821
+ await audit(session, event, tenantRef);
5822
+ const answer = VERIFY_ANSWERS[found.outcome];
5823
+ if (answer)
5824
+ return identityPage(session, answer.status, answer.text(found.domain ?? 'The domain'));
5825
+ if (found.outcome in DOMAIN_REFUSALS)
5826
+ return domainRefused(session, found.outcome);
5827
+ return identityPage(session, 409, 'The record isn’t there.');
5828
+ }
5829
+ async function releaseDomain(session, form) {
5830
+ const tenantRef = teamTenantOf(session);
5831
+ if (!domainStore || tenantRef === undefined)
5832
+ return nothingHere(session);
5833
+ const id = form.get('id') ?? '';
5834
+ const domain = (await domainStore.list(tenantRef)).find((listed) => listed.id === id);
5835
+ const refusal = await domainStore.release(tenantRef, personOf(session), id);
5836
+ if (refusal)
5837
+ return domainRefused(session, refusal);
5838
+ await audit(session, { action: 'domain.released', targetType: 'domain', targetRef: id, metadata: { domain: domain?.ascii ?? '' } }, tenantRef);
5839
+ return redirect(303, `${root}/settings/identity`);
5840
+ }
5841
+ async function changeJoinPolicy(session, form) {
5842
+ const tenantRef = teamTenantOf(session);
5843
+ if (!domainStore || tenantRef === undefined)
5844
+ return nothingHere(session);
5845
+ const id = form.get('id') ?? '';
5846
+ const policy = form.get('policy') ?? '';
5847
+ if (policy !== 'off' && policy !== 'request' && policy !== 'auto')
5848
+ return identityPage(session, 400, 'Choose who may join.');
5849
+ const domain = (await domainStore.list(tenantRef)).find((listed) => listed.id === id);
5850
+ const refusal = await domainStore.setJoinPolicy(tenantRef, personOf(session), id, policy);
5851
+ if (refusal)
5852
+ return domainRefused(session, refusal);
5853
+ await audit(session, { action: 'domain.join_policy_changed', targetType: 'domain', targetRef: id, metadata: { domain: domain?.ascii ?? '', policy } }, tenantRef);
5854
+ return identityPage(session, 200, `${JOIN_POLICY_WORDS[policy]}: saved for ${domain?.unicode ?? 'the domain'}.`);
5855
+ }
5856
+ async function restrictInvites(session, form) {
5857
+ const tenantRef = teamTenantOf(session);
5858
+ if (!domainStore || tenantRef === undefined)
5859
+ return nothingHere(session);
5860
+ const on = form.get('on') === '1';
5861
+ const refusal = await domainStore.restrictInvites(tenantRef, personOf(session), on);
5862
+ if (refusal)
5863
+ return domainRefused(session, refusal);
5864
+ await audit(session, { action: on ? 'workspace.invites_restricted' : 'workspace.invites_unrestricted', targetType: 'workspace', targetRef: tenantRef }, tenantRef);
5865
+ return redirect(303, `${root}/settings/identity`);
5866
+ }
5867
+ /** Teams at the person's verified addresses' domains: ask to join, or join. */
5868
+ async function domainTeamsSection(session, checked) {
5869
+ if (!domainStore)
5870
+ return html ``;
5871
+ const emails = verifiedEmails(session);
5872
+ const found = emails.length > 0 ? await domainStore.discoverable(personOf(session), emails) : [];
5873
+ if (found.length === 0) {
5874
+ // A GitHub sign-in carries no address until asked: one look with those GitHub verified.
5875
+ if (session.idp === 'google')
5876
+ return html ``;
5877
+ return checked
5878
+ ? html `<p class="muted">No team has verified the domain of an address GitHub verified for you.</p>`
5879
+ : html `<p><a href="${root}/sign-in?then=%2Fteams&amp;email=1">${ICONS.github}Find your organization’s team</a></p>`;
5880
+ }
5881
+ const token = csrfToken(session);
5882
+ const rows = found.map((team) => html `<tr><td data-label="Team" class="strong">${team.name}</td><td data-label="Domain" class="muted">${team.domain}</td><td class="actions">${team.requested
5883
+ ? html `<span class="pill held">Asked</span>`
5884
+ : html `<form class="inline" method="post" action="${root}/join/request"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="team" value="${team.tenantRef}"><button class="primary" type="submit">${team.joinPolicy === 'auto' && joinEvidence(session) === 'google_workspace'
5885
+ ? html `Join ${team.name}`
5886
+ : html `Ask to join<span class="visually-hidden"> ${team.name}</span>`}</button></form>`}</td></tr>`);
5887
+ return html `<div class="table"><table><caption class="visually-hidden">Teams at your domain</caption><thead><tr><th>At your domain</th><th>Domain</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${rows}</tbody></table></div>`;
5888
+ }
5889
+ async function requestToJoin(session, form) {
5890
+ if (!domainStore)
5891
+ return nothingHere(session);
5892
+ const team = form.get('team') ?? '';
5893
+ const offered = (await domainStore.discoverable(personOf(session), verifiedEmails(session))).find((found) => found.tenantRef === team);
5894
+ if (!offered)
5895
+ return teamsPage(session, 404, DOMAIN_REFUSALS.not_found.text);
5896
+ const asked = await domainStore.requestToJoin(team, personOf(session), verifiedEmails(session), joinEvidence(session), await memberCapOf(team));
5897
+ if ('refusal' in asked)
5898
+ return teamsPage(session, DOMAIN_REFUSALS[asked.refusal].status, DOMAIN_REFUSALS[asked.refusal].text);
5899
+ const person = personOf(session);
5900
+ if (asked.outcome === 'joined') {
5901
+ await audit(session, { action: 'member.joined', actorRole: 'viewer', targetType: 'person', targetRef: person, metadata: { domain: offered.domainKey } }, team);
5902
+ return moveTo(session, team.slice('team:'.length), '');
5903
+ }
5904
+ await audit(session, { action: 'member.join_requested', actorRole: null, targetType: 'person', targetRef: person, metadata: { domain: offered.domainKey, evidence: joinEvidence(session) } }, team);
5905
+ return teamsPage(session, 200, `Asked to join ${offered.name}. An owner or admin decides.`);
5906
+ }
5907
+ async function decideJoinRequest(session, form) {
5908
+ const tenantRef = teamTenantOf(session);
5909
+ if (!domainStore || tenantRef === undefined)
5910
+ return nothingHere(session);
5911
+ const decision = form.get('decision');
5912
+ if (decision !== 'approve' && decision !== 'decline')
5913
+ return refusalPage(session, 'not_found');
5914
+ const role = slotlockJoinRole(form.get('role'));
5915
+ if (decision === 'approve' && role === null)
5916
+ return settingsPage(session, 400, 'Choose a role.', undefined, 'members');
5917
+ const id = form.get('id') ?? '';
5918
+ const decided = await domainStore.decide({ tenantRef, actor: personOf(session), id, decision, role, memberCap: await memberCapOf(tenantRef) });
5919
+ if (decided.refusal) {
5920
+ const { status, text } = DOMAIN_REFUSALS[decided.refusal];
5921
+ return settingsPage(session, status, text, undefined, 'members');
5922
+ }
5923
+ await audit(session, {
5924
+ action: decision === 'approve' ? 'member.join_approved' : 'member.join_declined',
5925
+ targetType: 'person',
5926
+ ...(decided.person ? { targetRef: decided.person } : {}),
5927
+ ...(role && decision === 'approve' ? { metadata: { role } } : {}),
5928
+ }, tenantRef);
5929
+ return redirect(303, `${root}/settings/members`);
5930
+ }
5931
+ // ---- Single sign-on through a team's own identity provider (teams phase 5.3–5.5) ----
5932
+ /** What a refused single sign-on change or sign-in says, and its status. */
5933
+ const SSO_REFUSALS = Object.freeze({
5934
+ not_found: { status: 404, text: 'That is no longer here.' },
5935
+ not_allowed: { status: 403, text: 'Only an owner can change the team’s single sign-on.' },
5936
+ no_verified_domain: { status: 409, text: 'Verify a domain first: single sign-on trusts addresses only there.' },
5937
+ secret_required: { status: 400, text: 'Enter the client secret.' },
5938
+ conflict: { status: 409, text: 'The connection changed since this page was opened. Reload it.' },
5939
+ stale_config: { status: 409, text: 'The connection changed during the test. Test it again.' },
5940
+ untested: { status: 409, text: 'Test the connection as it is now before turning it on.' },
5941
+ no_breakglass_owner: { status: 409, text: 'Keep an owner who signs in with GitHub or Google first, for when the provider is down.' },
5942
+ role_not_allowed: { status: 400, text: 'People the provider adds join as Viewer, Member or Developer.' },
5943
+ owner_not_mappable: { status: 400, text: 'No group makes an owner: owners are made in Slotlock.' },
5944
+ invalid_value: { status: 400, text: 'Enter the group as the provider sends it.' },
5945
+ mapping_limit: { status: 403, text: 'A team maps up to 50 groups.' },
5946
+ connection_inactive: { status: 409, text: 'That team’s single sign-on is off.' },
5947
+ guest_refused: { status: 403, text: 'Guests of the directory cannot sign in to this team.' },
5948
+ not_member: { status: 403, text: 'You are not a member of this team. Ask an owner to add you.' },
5949
+ identity_taken: { status: 409, text: 'That account is linked to someone else in Slotlock.' },
5950
+ identity_untrusted: { status: 403, text: 'Use your work account at the team’s verified domain.' },
5951
+ hd_mismatch: { status: 403, text: 'Use a Google Workspace account at the team’s verified domain.' },
5952
+ member_cap: TEAM_REFUSALS.member_cap,
5953
+ invalid_max_age: { status: 400, text: 'Sessions last from 1 to 168 hours.' },
5954
+ no_active_connection: { status: 409, text: 'Turn the connection on first.' },
5955
+ stale_sso_proof: { status: 409, text: 'Sign in with the team’s single sign-on first, then turn this on within 10 minutes.' },
5956
+ });
5957
+ /** The providers, as the Identity page names them. */
5958
+ const SSO_KIND_WORDS = Object.freeze({
5959
+ okta: 'Okta',
5960
+ entra: 'Microsoft Entra ID',
5961
+ google_workspace: 'Google Workspace',
5962
+ oidc: 'Other OpenID Connect',
5963
+ });
5964
+ const ssoRedirectUri = (id) => `${publicRoot}/sso/callback/${id}`;
5965
+ const ssoSecretContext = (tenantRef, id) => `${tenantRef}|${id}|v1`;
5966
+ const ssoRefused = (session, refusal) => identityPage(session, SSO_REFUSALS[refusal].status, SSO_REFUSALS[refusal].text);
5967
+ /** The team's connection on its Identity page: set it up, test it, turn it on, and who it lets in. */
5968
+ async function ssoSection(session, tenantRef) {
5969
+ if (!ssoStore)
5970
+ return html ``;
5971
+ const [connection, mappings] = await Promise.all([ssoStore.connection(tenantRef), ssoStore.mappings(tenantRef)]);
5972
+ const token = csrfToken(session);
5973
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
5974
+ const kinds = (selected) => ['okta', 'entra', 'google_workspace', 'oidc'].map((kind) => html `<option value="${kind}"${kind === selected ? html ` selected` : html ``}>${SSO_KIND_WORDS[kind]}</option>`);
5975
+ const form = html `<form class="sso-form" method="post" action="${root}/identity/sso">${hidden()}<label for="sso-kind">Provider</label><select id="sso-kind" name="kind">${kinds(connection?.kind ?? 'okta')}</select><label for="sso-issuer">Issuer URL, or Entra tenant ID</label><input id="sso-issuer" name="issuer" maxlength="500" autocomplete="off" spellcheck="false" required value="${connection ? (connection.kind === 'entra' ? (connection.entraTenant ?? '') : connection.issuer) : ''}"><label for="sso-client">Client ID</label><input id="sso-client" name="client_id" maxlength="255" autocomplete="off" spellcheck="false" required value="${connection?.clientId ?? ''}"><label for="sso-secret">Client secret</label><input id="sso-secret" name="client_secret" type="password" maxlength="1024" autocomplete="off"${connection ? html ` placeholder="••••${connection.secretLast4 ?? ''}"` : html ` required`}><button class="primary" type="submit">${connection ? 'Save' : 'Connect'}</button></form>`;
5976
+ if (!connection)
5977
+ return html `<h2>Single sign-on</h2>${form}`;
5978
+ const tested = connection.testedVersion === connection.configVersion;
5979
+ const [pill, word] = connection.status === 'active' ? ['active', 'On'] : connection.status === 'disabled' ? ['revoked', 'Off'] : ['held', tested ? 'Tested' : 'Not tested'];
5980
+ const id = html `<input type="hidden" name="id" value="${connection.id}">`;
5981
+ const claims = connection.lastTestClaims;
5982
+ const testRow = claims
5983
+ ? html `<div class="row"><span class="row-label">Last test</span><div class="row-value"><code>${JSON.stringify(claims)}</code></div></div>`
5984
+ : html ``;
5985
+ const status = html `<div class="set-card"><div class="row"><span class="row-label">${SSO_KIND_WORDS[connection.kind]}</span><div class="row-value"><span class="pill ${pill}">${word}</span> <span class="muted">${connection.issuer}</span></div></div><div class="row"><span class="row-label">Redirect URI</span><div class="row-value">${copyField('sso-redirect', ssoRedirectUri(connection.id))}</div></div>${testRow}<div class="row"><span class="row-label">Check</span><div class="row-value"><form class="inline" method="post" action="${root}/identity/sso/test">${hidden()}${id}<button type="submit">Test sign-in</button></form>${connection.status === 'active'
5986
+ ? html `<form class="inline" method="post" action="${root}/identity/sso/status">${hidden()}${id}<input type="hidden" name="status" value="disabled"><button type="submit">Turn off</button></form>`
5987
+ : html `<form class="inline" method="post" action="${root}/identity/sso/status">${hidden()}${id}<input type="hidden" name="status" value="active"><button class="primary" type="submit"${tested ? html `` : html ` disabled`}>Turn on</button></form>`}</div></div></div>`;
5988
+ const roleOptions = (selected, choices) => choices.map((role) => html `<option value="${role}"${role === selected ? html ` selected` : html ``}>${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}</option>`);
5989
+ const provisioning = html `<h3>Who joins</h3><form class="sso-form" method="post" action="${root}/identity/sso/provisioning">${hidden()}<label><input type="checkbox" name="jit" value="1"${connection.jitEnabled ? html ` checked` : html ``}> Add people at the verified domains on their first sign-in</label><label for="sso-default-role">As</label><select id="sso-default-role" name="default_role">${roleOptions(connection.defaultRole, ['viewer', 'member', 'developer'])}</select><label for="sso-groups-claim">Groups claim</label><input id="sso-groups-claim" name="groups_claim" maxlength="100" value="${connection.groupsClaim}"><button type="submit">Save</button></form>${mappings.length > 0
5990
+ ? html `<div class="table"><table><thead><tr><th>Group</th><th>Role</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${mappings.map((mapping) => html `<tr><td data-label="Group" class="strong">${mapping.value}</td><td data-label="Role">${SLOTLOCK_WORKSPACE_ROLE_NAMES[mapping.role]}</td><td class="actions"><form class="inline" method="post" action="${root}/identity/sso/mapping">${hidden()}<input type="hidden" name="value" value="${mapping.value}"><input type="hidden" name="role" value=""><button class="danger" type="submit">Remove<span class="visually-hidden"> ${mapping.value}</span></button></form></td></tr>`)}</tbody></table></div>`
5991
+ : html ``}<form class="invite" method="post" action="${root}/identity/sso/mapping">${hidden()}<label for="sso-group">Group</label><input id="sso-group" name="value" maxlength="255" autocomplete="off" required><label class="visually-hidden" for="sso-group-role">Role</label><select id="sso-group-role" name="role">${roleOptions('member', ['admin', 'developer', 'member', 'viewer', 'billing'])}</select><button type="submit">Map group</button></form>`;
5992
+ const enforcement = html `<h3>Require single sign-on</h3><form class="sso-form" method="post" action="${root}/identity/sso/require">${hidden()}<input type="hidden" name="on" value="${connection.requireSso ? '0' : '1'}"><label for="sso-max-age">Sign in again every (hours)</label><input id="sso-max-age" name="max_age" type="number" min="1" max="168" value="${String(connection.maxAgeHours)}"><button type="submit"${connection.status === 'active' || connection.requireSso ? html `` : html ` disabled`}>${connection.requireSso ? 'Stop requiring it' : 'Require it'}</button></form>${connection.status === 'active' && !connection.requireSso && session.sc === undefined
5993
+ ? html `<form class="inline" method="post" action="${root}/sso/proof">${hidden()}<input type="hidden" name="team" value="${tenantRef}"><button type="submit">Sign in with single sign-on</button></form>`
5994
+ : html ``}`;
5995
+ const remove = html `<details class="act danger"><summary>Delete connection</summary><div class="pop"><p>People who sign in only through it are erased with it.</p><form method="post" action="${root}/identity/sso/delete">${hidden()}${id}<button class="danger" type="submit">Delete connection</button></form></div></details>`;
5996
+ return html `<h2>Single sign-on</h2>${status}${form}${provisioning}${enforcement}${remove}`;
5997
+ }
5998
+ async function saveSsoConnection(session, form) {
5999
+ const tenantRef = teamTenantOf(session);
6000
+ if (!ssoStore || !options.sso || tenantRef === undefined)
6001
+ return nothingHere(session);
6002
+ const kind = form.get('kind');
6003
+ if (kind !== 'okta' && kind !== 'entra' && kind !== 'google_workspace' && kind !== 'oidc')
6004
+ return identityPage(session, 400, 'Choose the provider.');
6005
+ const typed = (form.get('issuer') ?? '').trim();
6006
+ const clientId = (form.get('client_id') ?? '').trim();
6007
+ const secret = form.get('client_secret') ?? '';
6008
+ if (clientId.length === 0 || clientId.length > 255 || /\s/.test(clientId))
6009
+ return identityPage(session, 400, 'Enter the client ID.');
6010
+ let issuer;
6011
+ let entraTenant = null;
6012
+ if (kind === 'entra') {
6013
+ const pinned = slotlockEntraIssuer(typed);
6014
+ if (!pinned)
6015
+ return identityPage(session, 400, 'Enter your Entra tenant ID (a GUID): common, organizations and consumers are not one tenant.');
6016
+ issuer = pinned;
6017
+ entraTenant = typed.toLowerCase();
6018
+ }
6019
+ else if (kind === 'google_workspace') {
6020
+ issuer = 'https://accounts.google.com';
6021
+ }
6022
+ else {
6023
+ if (!slotlockOidcUrlAllowed(typed))
6024
+ return identityPage(session, 400, 'Enter the issuer URL: https, on a public host name.');
6025
+ issuer = typed;
6026
+ }
6027
+ let provider;
6028
+ try {
6029
+ provider = await discoverSlotlockOidc(issuer, { fetch: ssoFetch });
6030
+ }
6031
+ catch (error) {
6032
+ return identityPage(session, 502, `The issuer’s discovery document did not check out: ${error instanceof Error ? error.message : 'no answer'}.`);
6033
+ }
6034
+ const existing = await ssoStore.connection(tenantRef);
6035
+ const id = existing?.id ?? randomUUID();
6036
+ if (!existing && secret.length === 0)
6037
+ return ssoRefused(session, 'secret_required');
6038
+ if (secret.length > 1024 || /[\r\n]/.test(secret))
6039
+ return identityPage(session, 400, 'That is not a client secret.');
6040
+ const sealed = secret.length > 0 ? await options.sso.secretBox.seal(secret, ssoSecretContext(tenantRef, id)) : null;
6041
+ const saved = await ssoStore.save({
6042
+ tenantRef,
6043
+ actor: personOf(session),
6044
+ id,
6045
+ kind,
6046
+ issuer: provider.issuer,
6047
+ clientId,
6048
+ secretSealed: sealed,
6049
+ secretLast4: secret.length > 0 ? secret.slice(-4) : null,
6050
+ authorizationEndpoint: provider.authorizationEndpoint,
6051
+ tokenEndpoint: provider.tokenEndpoint,
6052
+ jwksUri: provider.jwksUri,
6053
+ algorithms: provider.algorithms,
6054
+ issParameter: provider.issParameter,
6055
+ entraTenant,
6056
+ });
6057
+ if ('refusal' in saved)
6058
+ return ssoRefused(session, saved.refusal);
6059
+ await audit(session, { action: 'sso.connection_saved', targetType: 'sso_connection', targetRef: saved.id, metadata: { kind, issuer: provider.issuer } }, tenantRef);
6060
+ if (saved.enforcementOff) {
6061
+ await audit(session, { action: 'sso.not_required', targetType: 'workspace', targetRef: tenantRef, metadata: { reason: 'connection_changed' } }, tenantRef);
6062
+ }
6063
+ return redirect(303, `${root}/settings/identity`);
6064
+ }
6065
+ async function setSsoStatus(session, form) {
6066
+ const tenantRef = teamTenantOf(session);
6067
+ if (!ssoStore || tenantRef === undefined)
6068
+ return nothingHere(session);
6069
+ const status = form.get('status') === 'active' ? 'active' : 'disabled';
6070
+ const id = form.get('id') ?? '';
6071
+ const refusal = await ssoStore.setStatus(tenantRef, personOf(session), id, status);
6072
+ if (refusal)
6073
+ return ssoRefused(session, refusal);
6074
+ await audit(session, { action: status === 'active' ? 'sso.connection_activated' : 'sso.connection_disabled', targetType: 'sso_connection', targetRef: id }, tenantRef);
6075
+ return redirect(303, `${root}/settings/identity`);
6076
+ }
6077
+ async function deleteSsoConnection(session, form) {
6078
+ const tenantRef = teamTenantOf(session);
6079
+ if (!ssoStore || tenantRef === undefined)
6080
+ return nothingHere(session);
6081
+ const id = form.get('id') ?? '';
6082
+ const refusal = await ssoStore.remove(tenantRef, personOf(session), id);
6083
+ if (refusal)
6084
+ return ssoRefused(session, refusal);
6085
+ await audit(session, { action: 'sso.connection_deleted', targetType: 'sso_connection', targetRef: id }, tenantRef);
6086
+ return redirect(303, `${root}/settings/identity`);
6087
+ }
6088
+ async function setSsoProvisioning(session, form) {
6089
+ const tenantRef = teamTenantOf(session);
6090
+ if (!ssoStore || tenantRef === undefined)
6091
+ return nothingHere(session);
6092
+ const jit = form.get('jit') === '1';
6093
+ const defaultRole = form.get('default_role') ?? '';
6094
+ const groupsClaim = (form.get('groups_claim') ?? '').trim();
6095
+ if (groupsClaim !== '' && !/^[A-Za-z0-9_:./-]{1,100}$/.test(groupsClaim))
6096
+ return identityPage(session, 400, 'Enter the groups claim as the provider names it.');
6097
+ const refusal = await ssoStore.setProvisioning(tenantRef, personOf(session), jit, defaultRole, groupsClaim);
6098
+ if (refusal)
6099
+ return ssoRefused(session, refusal);
6100
+ await audit(session, { action: 'sso.provisioning_changed', targetType: 'workspace', targetRef: tenantRef, metadata: { jit, defaultRole } }, tenantRef);
6101
+ return redirect(303, `${root}/settings/identity`);
6102
+ }
6103
+ async function setSsoMapping(session, form) {
6104
+ const tenantRef = teamTenantOf(session);
6105
+ if (!ssoStore || tenantRef === undefined)
6106
+ return nothingHere(session);
6107
+ const value = (form.get('value') ?? '').trim();
6108
+ const role = form.get('role') || null;
6109
+ const refusal = await ssoStore.setMapping(tenantRef, personOf(session), value, role);
6110
+ if (refusal)
6111
+ return ssoRefused(session, refusal);
6112
+ await audit(session, { action: 'sso.mapping_changed', targetType: 'workspace', targetRef: tenantRef, metadata: { group: value.slice(0, 100), role: role ?? 'none' } }, tenantRef);
6113
+ return redirect(303, `${root}/settings/identity`);
6114
+ }
6115
+ async function setRequireSso(session, form) {
6116
+ const tenantRef = teamTenantOf(session);
6117
+ if (!ssoStore || tenantRef === undefined)
6118
+ return nothingHere(session);
6119
+ const on = form.get('on') === '1';
6120
+ const maxAge = Number(form.get('max_age') ?? '');
6121
+ const refusal = await ssoStore.requireSso(tenantRef, personOf(session), session.sid, on, Number.isSafeInteger(maxAge) ? maxAge : 0);
6122
+ if (refusal)
6123
+ return ssoRefused(session, refusal);
6124
+ await audit(session, { action: on ? 'sso.required' : 'sso.not_required', targetType: 'workspace', targetRef: tenantRef, metadata: { maxAgeHours: maxAge } }, tenantRef);
6125
+ return redirect(303, `${root}/settings/identity`);
6126
+ }
6127
+ /** The page a member meets at a team that requires single sign-on: sign in through it, or (an Owner) bypass. */
6128
+ function ssoRequiredPage(session, tenantRef, name, mayBypass) {
6129
+ const token = csrfToken(session);
6130
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="team" value="${tenantRef}">`;
6131
+ const content = html `${pageHead(name)}<p>${name} requires single sign-on.</p><p class="actions"><form class="inline" method="post" action="${root}/sso/proof">${hidden()}<button class="primary" type="submit">Sign in with single sign-on</button></form>${mayBypass
6132
+ ? html `<form class="inline" method="post" action="${root}/sso/bypass">${hidden()}<button type="submit">Use owner bypass</button></form>`
6133
+ : html ``}</p>${mayBypass ? html `<p class="fine">An owner’s bypass lasts an hour, in this browser, and every other owner can see it in the audit log.</p>` : html ``}`;
6134
+ return respond(403, layout(name, content, session));
6135
+ }
6136
+ /** Start a sign-in through a team's provider: to sign in alone, prove oneself (or link) while signed in, or test it. */
6137
+ async function startSsoSignIn(connection, purpose, person, version) {
6138
+ const state = randomBytes(32).toString('base64url');
6139
+ const verifier = randomBytes(32).toString('base64url');
6140
+ const nonce = randomBytes(24).toString('base64url');
6141
+ const target = new URL(connection.authorizationEndpoint);
6142
+ target.search = new URLSearchParams({
6143
+ client_id: connection.clientId,
6144
+ redirect_uri: ssoRedirectUri(connection.id),
6145
+ response_type: 'code',
6146
+ scope: 'openid email profile',
6147
+ state,
6148
+ nonce,
6149
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'),
6150
+ code_challenge_method: 'S256',
6151
+ ...(purpose === 'test' ? { prompt: 'login' } : {}),
6152
+ }).toString();
6153
+ const sealed = seal('slotlock-dashboard-sso-v1', {
6154
+ state,
6155
+ verifier,
6156
+ nonce,
6157
+ conn: connection.id,
6158
+ purpose,
6159
+ exp: now() + SIGN_IN_TTL_MS,
6160
+ ...(person !== undefined ? { person } : {}),
6161
+ ...(version !== undefined ? { version } : {}),
6162
+ });
6163
+ return redirect(302, target.href, [setCookie(SSO_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
6164
+ }
6165
+ async function testSsoConnection(session) {
6166
+ const tenantRef = teamTenantOf(session);
6167
+ if (!ssoStore || tenantRef === undefined)
6168
+ return nothingHere(session);
6169
+ const connection = await ssoStore.connection(tenantRef);
6170
+ if (!connection)
6171
+ return ssoRefused(session, 'not_found');
6172
+ return startSsoSignIn({ ...connection, tenantRef }, 'test', personOf(session), connection.configVersion);
6173
+ }
6174
+ /** A signed-in member's sign-in through their team's provider: to link it, or prove themselves to a team that requires it. */
6175
+ async function startSsoProof(session, form) {
6176
+ if (!ssoStore)
6177
+ return nothingHere(session);
6178
+ const team = form.get('team') ?? '';
6179
+ const connection = TEAM_ID.test(team.replace(/^team:/, '')) ? await ssoStore.connection(team) : null;
6180
+ if (!connection || connection.status !== 'active')
6181
+ return teamsPage(session, 409, SSO_REFUSALS.connection_inactive.text);
6182
+ return startSsoSignIn({ ...connection, tenantRef: team }, 'proof', personOf(session));
6183
+ }
6184
+ const SCIM_REFUSALS = Object.freeze({
6185
+ not_found: { status: 404, text: 'That is no longer here.' },
6186
+ not_allowed: { status: 403, text: 'Only an owner can change the team’s provisioning.' },
6187
+ not_enabled: { status: 409, text: 'Turn on SCIM first.' },
6188
+ no_verified_domain: { status: 409, text: 'Verify a domain first: the directory adds people only there.' },
6189
+ key_policy: { status: 400, text: 'Choose what happens to a deactivated member’s API keys.' },
6190
+ token_limit: { status: 403, text: 'A team keeps two tokens. Revoke one first.' },
6191
+ bad_token: { status: 401, text: 'That token is not this team’s.' },
6192
+ wrong_workspace: { status: 401, text: 'That token is not this team’s.' },
6193
+ domain_not_verified: { status: 400, text: 'The directory adds people at the verified domains only.' },
6194
+ uniqueness: { status: 409, text: 'That name is taken.' },
6195
+ owner_protected: { status: 409, text: 'The directory never changes an owner.' },
6196
+ invalid_member: { status: 400, text: 'That member is not in the directory.' },
6197
+ owner_not_mappable: { status: 400, text: 'No group makes an owner: owners are made in Slotlock.' },
6198
+ role_not_allowed: { status: 400, text: 'Choose a role.' },
6199
+ });
6200
+ const scimRefused = (session, refusal) => identityPage(session, SCIM_REFUSALS[refusal].status, SCIM_REFUSALS[refusal].text);
6201
+ /** A token just made: shown on the page that answers, once. */
6202
+ const scimTokenShown = new WeakMap();
6203
+ const scimBaseUrl = (tenantRef) => `${publicRoot}/scim/v2/${tenantRef.slice('team:'.length)}`;
6204
+ /** A team's SCIM provisioning on its Identity page: off with the Owner's choice of key policy, or its address, tokens and groups. */
6205
+ async function scimSection(session, tenantRef) {
6206
+ if (!scimStore)
6207
+ return html ``;
6208
+ const state = await scimStore.state(tenantRef);
6209
+ const hidden = () => html `<input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}">`;
6210
+ if (!state.enabled) {
6211
+ return html `<h2>Provisioning</h2><form class="sso-form" method="post" action="${root}/identity/scim/enable">${hidden()}<fieldset><legend>When the directory deactivates someone, their API keys</legend><label><input type="radio" name="keys" value="revoke" required> Are revoked</label><label><input type="radio" name="keys" value="keep" required> Keep working</label></fieldset><button class="primary" type="submit">Turn on SCIM</button></form>`;
6212
+ }
6213
+ const shown = scimTokenShown.get(session);
6214
+ const tokens = state.tokens.map((token) => html `<tr><td data-label="Token" class="strong"><code>${token.prefix}…</code></td><td data-label="Made">${formatDate(token.createdAt)}</td><td data-label="Last used">${token.lastUsedAt ? formatDate(token.lastUsedAt) : 'Never'}</td><td class="actions"><form class="inline" method="post" action="${root}/identity/scim/token/revoke">${hidden()}<input type="hidden" name="id" value="${token.id}"><button class="danger" type="submit">Revoke<span class="visually-hidden"> ${token.prefix}</span></button></form></td></tr>`);
6215
+ const card = html `<div class="set-card"><div class="row"><span class="row-label">SCIM URL</span><div class="row-value">${copyField('scim-url', scimBaseUrl(tenantRef))}</div></div>${shown ? html `<div class="row"><span class="row-label">New token</span><div class="row-value">${copyField('scim-token', shown)}</div></div>` : html ``}<div class="row"><span class="row-label">Deactivated members’ keys</span><div class="row-value">${state.keyPolicy === 'revoke' ? 'Revoked' : 'Keep working'}</div></div></div>`;
6216
+ const tokenTable = tokens.length > 0
6217
+ ? html `<div class="table"><table><thead><tr><th>Token</th><th>Made</th><th>Last used</th><th><span class="visually-hidden">Actions</span></th></tr></thead><tbody>${tokens}</tbody></table></div>`
6218
+ : html ``;
6219
+ const { groups } = await scimStore.groups(tenantRef, null, 1, 100);
6220
+ const roleChoices = ['admin', 'developer', 'member', 'viewer', 'billing'];
6221
+ const reserved = (name) => /^slotlock-role-(admin|developer|member|viewer|billing)$/i.test(name);
6222
+ const groupRows = groups.map((group) => html `<tr><td data-label="Group" class="strong">${group.displayName}</td><td data-label="People">${String(group.members.length)}</td><td data-label="Role">${reserved(group.displayName)
6223
+ ? SLOTLOCK_WORKSPACE_ROLE_NAMES[group.displayName.slice('slotlock-role-'.length).toLowerCase()]
6224
+ : html `<form class="inline" method="post" action="${root}/identity/scim/group">${hidden()}<input type="hidden" name="id" value="${group.id}"><label class="visually-hidden" for="scim-group-${group.id}">Role for ${group.displayName}</label><select id="scim-group-${group.id}" name="role"><option value="">None</option>${roleChoices.map((role) => html `<option value="${role}"${group.role === role ? html ` selected` : html ``}>${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}</option>`)}</select><button type="submit">Save</button></form>`}</td></tr>`);
6225
+ const groupTable = groupRows.length > 0
6226
+ ? html `<h3>Groups</h3><div class="table"><table><thead><tr><th>Group</th><th>People</th><th>Role</th></tr></thead><tbody>${groupRows}</tbody></table></div>`
6227
+ : html ``;
6228
+ const off = html `<details class="act danger"><summary>Turn off SCIM</summary><div class="pop"><p>Its tokens stop working. Members it added stay.</p><form method="post" action="${root}/identity/scim/disable">${hidden()}<button class="danger" type="submit">Turn off SCIM</button></form></div></details>`;
6229
+ return html `<h2>Provisioning</h2>${card}${tokenTable}<form class="inline" method="post" action="${root}/identity/scim/token">${hidden()}<button type="submit">New token</button></form>${groupTable}${off}`;
6230
+ }
6231
+ async function enableScim(session, form) {
6232
+ const tenantRef = teamTenantOf(session);
6233
+ if (!scimStore || tenantRef === undefined)
6234
+ return nothingHere(session);
6235
+ const keys = form.get('keys');
6236
+ if (keys !== 'keep' && keys !== 'revoke')
6237
+ return scimRefused(session, 'key_policy');
6238
+ const refusal = await scimStore.enable(tenantRef, personOf(session), keys);
6239
+ if (refusal)
6240
+ return scimRefused(session, refusal);
6241
+ await audit(session, { action: 'scim.enabled', targetType: 'workspace', targetRef: tenantRef, metadata: { keys } }, tenantRef);
6242
+ return redirect(303, `${root}/settings/identity`);
6243
+ }
6244
+ async function disableScim(session) {
6245
+ const tenantRef = teamTenantOf(session);
6246
+ if (!scimStore || tenantRef === undefined)
6247
+ return nothingHere(session);
6248
+ const refusal = await scimStore.disable(tenantRef, personOf(session));
6249
+ if (refusal)
6250
+ return scimRefused(session, refusal);
6251
+ await audit(session, { action: 'scim.disabled', targetType: 'workspace', targetRef: tenantRef }, tenantRef);
6252
+ return redirect(303, `${root}/settings/identity`);
6253
+ }
6254
+ async function createScimToken(session) {
6255
+ const tenantRef = teamTenantOf(session);
6256
+ if (!scimStore || tenantRef === undefined)
6257
+ return nothingHere(session);
6258
+ const made = await scimStore.createToken(tenantRef, personOf(session));
6259
+ if ('refusal' in made)
6260
+ return scimRefused(session, made.refusal);
6261
+ await audit(session, { action: 'scim.token_created', targetType: 'scim_token', targetRef: made.prefix }, tenantRef);
6262
+ scimTokenShown.set(session, made.token);
6263
+ return identityPage(session, 201, 'Copy the token into your identity provider now: it is not shown again.');
6264
+ }
6265
+ async function revokeScimToken(session, form) {
6266
+ const tenantRef = teamTenantOf(session);
6267
+ if (!scimStore || tenantRef === undefined)
6268
+ return nothingHere(session);
6269
+ const id = form.get('id') ?? '';
6270
+ const refusal = await scimStore.revokeToken(tenantRef, personOf(session), id);
6271
+ if (refusal)
6272
+ return scimRefused(session, refusal);
6273
+ await audit(session, { action: 'scim.token_revoked', targetType: 'scim_token', targetRef: id }, tenantRef);
6274
+ return redirect(303, `${root}/settings/identity`);
6275
+ }
6276
+ async function mapScimGroup(session, form) {
6277
+ const tenantRef = teamTenantOf(session);
6278
+ if (!scimStore || tenantRef === undefined)
6279
+ return nothingHere(session);
6280
+ const id = form.get('id') ?? '';
6281
+ const role = form.get('role') || null;
6282
+ const refusal = await scimStore.mapGroup(tenantRef, personOf(session), id, role);
6283
+ if (refusal)
6284
+ return scimRefused(session, refusal);
6285
+ await audit(session, { action: 'scim.group_mapped', targetType: 'scim_group', targetRef: id, metadata: { role } }, tenantRef);
6286
+ return redirect(303, `${root}/settings/identity`);
6287
+ }
6288
+ async function useSsoBypass(session, form) {
6289
+ if (!ssoStore)
6290
+ return nothingHere(session);
6291
+ const team = form.get('team') ?? '';
6292
+ if (!TEAM_ID.test(team.replace(/^team:/, '')))
6293
+ return nothingHere(session);
6294
+ // As fresh as any Owner's power: a sign-in in the last 15 minutes.
6295
+ if (!freshlySignedIn(session))
6296
+ return confirmPage(session, 'workspace.own', '/teams');
6297
+ const refusal = await ssoStore.bypass(team, personOf(session), session.sid);
6298
+ if (refusal)
6299
+ return teamsPage(session, 403, 'Only an owner who signs in with GitHub or Google can bypass single sign-on.');
6300
+ await audit(session, { action: 'sso.bypass_used', actorRole: 'owner', targetType: 'workspace', targetRef: team }, team);
6301
+ return moveTo(session, team.slice('team:'.length), '');
6302
+ }
6303
+ /** The claims a test sign-in shows the Owner: what decides who someone is and what they get, no token. */
6304
+ function ssoTestClaims(claims, groupsClaim) {
6305
+ const pick = (name) => (claims[name] === undefined ? {} : { [name]: claims[name] });
6306
+ const groups = claims[groupsClaim];
6307
+ return {
6308
+ ...pick('iss'),
6309
+ ...pick('sub'),
6310
+ ...pick('tid'),
6311
+ ...pick('oid'),
6312
+ ...pick('email'),
6313
+ ...pick('email_verified'),
6314
+ ...pick('xms_edov'),
6315
+ ...pick('hd'),
6316
+ ...pick('acct'),
6317
+ ...(Array.isArray(groups) ? { groups: groups.slice(0, 20) } : isRecord(claims._claim_names) ? { groups: 'overage' } : {}),
6318
+ };
6319
+ }
6320
+ /** The callback of a team's provider: the code exchanged, the ID token checked, then the purpose served. */
6321
+ async function finishSsoSignIn(request, url, connectionId) {
6322
+ const clear = setCookie(SSO_COOKIE, '', 0);
6323
+ const pending = unseal('slotlock-dashboard-sso-v1', readCookies(request).get(SSO_COOKIE));
6324
+ const code = url.searchParams.get('code');
6325
+ const state = url.searchParams.get('state');
6326
+ if (!ssoStore ||
6327
+ !options.sso ||
6328
+ url.searchParams.has('error') ||
6329
+ !pending ||
6330
+ pending.conn !== connectionId ||
6331
+ typeof pending.state !== 'string' ||
6332
+ typeof pending.verifier !== 'string' ||
6333
+ typeof pending.nonce !== 'string' ||
6334
+ code === null ||
6335
+ code.length === 0 ||
6336
+ code.length > 2048 ||
6337
+ state === null ||
6338
+ !safeEqual(state, pending.state)) {
6339
+ return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
6340
+ }
6341
+ const connection = await ssoStore.connectionById(connectionId);
6342
+ if (!connection || (pending.purpose !== 'test' && connection.status !== 'active')) {
6343
+ return signedOutPage(409, SSO_REFUSALS.connection_inactive.text, [clear]);
6344
+ }
6345
+ // RFC 9207: the issuer that answered, when it says, must be this connection's (mix-up defence).
6346
+ const answeredBy = url.searchParams.get('iss');
6347
+ if ((answeredBy !== null || connection.issParameter) && answeredBy !== connection.issuer) {
6348
+ return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
6349
+ }
6350
+ if (!(await options.state.use('sign_in', `sso.${pending.state}`, pending.exp)))
6351
+ return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
6352
+ const secret = connection.secretSealed ? await options.sso.secretBox.open(connection.secretSealed, ssoSecretContext(connection.tenantRef, connection.id)) : null;
6353
+ if (secret === null) {
6354
+ report('Single sign-on refused: the client secret does not open');
6355
+ return signedOutPage(502, `${connection.name}’s single sign-on did not complete. Try again.`, [clear]);
6356
+ }
6357
+ let checked = null;
6358
+ try {
6359
+ // Okta and other providers take the client's secret by HTTP Basic (RFC 6749 2.3.1); Entra and
6360
+ // Google, in the body.
6361
+ const basic = connection.kind === 'okta' || connection.kind === 'oidc';
6362
+ const body = new URLSearchParams({
6363
+ grant_type: 'authorization_code',
6364
+ code,
6365
+ redirect_uri: ssoRedirectUri(connection.id),
6366
+ code_verifier: pending.verifier,
6367
+ ...(basic ? {} : { client_id: connection.clientId, client_secret: secret }),
6368
+ });
6369
+ if (!slotlockOidcUrlAllowed(connection.tokenEndpoint))
6370
+ throw new Error('token endpoint');
6371
+ const response = await ssoFetch(connection.tokenEndpoint, {
6372
+ method: 'POST',
6373
+ headers: {
6374
+ 'Content-Type': 'application/x-www-form-urlencoded',
6375
+ Accept: 'application/json',
6376
+ ...(basic
6377
+ ? { Authorization: `Basic ${btoa(`${encodeURIComponent(connection.clientId)}:${encodeURIComponent(secret)}`)}` }
6378
+ : {}),
6379
+ },
6380
+ body: body.toString(),
6381
+ redirect: 'manual',
6382
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
6383
+ });
6384
+ // Read to 64 KB at most: the endpoint is the Owner's to choose, and a token answer is small.
6385
+ const answer = response.ok ? await slotlockReadJsonBody(response, SSO_TOKEN_ANSWER_BYTES).catch(() => null) : null;
6386
+ if (!response.ok)
6387
+ await response.body?.cancel().catch(() => undefined);
6388
+ const token = answer !== null && typeof answer.id_token === 'string' ? answer.id_token : null;
6389
+ if (token !== null) {
6390
+ checked = await verifySlotlockIdToken(token, {
6391
+ issuers: connection.kind === 'google_workspace' ? SLOTLOCK_GOOGLE_ISSUERS : [connection.issuer],
6392
+ clientId: connection.clientId,
6393
+ algorithms: connection.algorithms,
6394
+ keys: ssoJwks.lookup(connection.jwksUri),
6395
+ nonce: pending.nonce,
6396
+ now: now(),
6397
+ });
6398
+ }
6399
+ else {
6400
+ report(`Single sign-on token exchange failed: HTTP ${response.status}`);
6401
+ }
6402
+ }
6403
+ catch (error) {
6404
+ report(`Single sign-on failed: ${error instanceof Error ? error.name : 'unknown error'}`);
6405
+ }
6406
+ if (!checked || 'refusal' in checked) {
6407
+ if (checked)
6408
+ report(`Single sign-on refused: the ID token did not check out (${checked.refusal})`);
6409
+ return signedOutPage(502, `${connection.name}’s single sign-on did not complete. Try again.`, [clear]);
6410
+ }
6411
+ const claims = checked.claims;
6412
+ const subject = claims.sub;
6413
+ // Entra: the token's tenant is the one pinned, the object id is the stable key, guests stay out,
6414
+ // and an email counts only when Entra says the domain's owner verified it (xms_edov).
6415
+ if (connection.kind === 'entra' && (claims.tid !== connection.entraTenant || typeof claims.oid !== 'string')) {
6416
+ return signedOutPage(403, `${connection.name}’s single sign-on did not complete. Try again.`, [clear]);
6417
+ }
6418
+ // The team's verified domains: an email is trusted only there, and a Google account only of one.
6419
+ const verifiedDomains = domainStore
6420
+ ? (await domainStore.list(connection.tenantRef)).filter((claim) => claim.status === 'verified').map((claim) => claim.ascii)
6421
+ : [];
6422
+ const hostedDomain = typeof claims.hd === 'string' ? slotlockEmailDomain(`x@${claims.hd}`) : null;
6423
+ if (connection.kind === 'google_workspace' && (hostedDomain === null || !verifiedDomains.includes(hostedDomain))) {
6424
+ // Any Google account can pass an "External" OAuth client: only the team's Workspace counts.
6425
+ const refused = SSO_REFUSALS.hd_mismatch;
6426
+ if (pending.purpose === 'signin')
6427
+ return signedOutPage(refused.status, refused.text, [clear]);
6428
+ const session = await readSession(request);
6429
+ if (!session)
6430
+ return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
6431
+ return pending.purpose === 'test' ? identityPage(session, refused.status, refused.text) : teamsPage(session, refused.status, refused.text);
6432
+ }
6433
+ const email = typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email) ? claims.email.toLowerCase() : null;
6434
+ const emailDomain = email === null ? null : slotlockEmailDomain(email);
6435
+ const emailOk = email !== null &&
6436
+ (connection.kind === 'entra'
6437
+ ? claims.xms_edov === true || claims.xms_edov === 'true' || claims.xms_edov === 1
6438
+ : connection.kind === 'google_workspace'
6439
+ ? (claims.email_verified === true || claims.email_verified === 'true') && emailDomain !== null && emailDomain === hostedDomain
6440
+ : // Okta and other providers: the team's directory vouches for its own domain's addresses,
6441
+ // unless the token itself says the address is unverified.
6442
+ claims.email_verified !== false && claims.email_verified !== 'false');
6443
+ const groupsValue = claims[connection.groupsClaim];
6444
+ const groups = Array.isArray(groupsValue) ? groupsValue.filter((group) => typeof group === 'string').slice(0, 200) : [];
6445
+ const overage = isRecord(claims._claim_names) && 'groups' in claims._claim_names;
6446
+ const guest = connection.kind === 'entra' && (claims.acct === 1 || claims.acct === '1');
6447
+ const entra = connection.kind === 'entra' ? { tid: claims.tid, oid: claims.oid } : null;
6448
+ const session = pending.purpose === 'signin' ? null : await readSession(request);
6449
+ if (pending.purpose === 'test') {
6450
+ if (!session || personOf(session) !== pending.person || typeof pending.version !== 'number')
6451
+ return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
6452
+ const shown = { ...ssoTestClaims(claims, connection.groupsClaim), trusted_email: emailOk };
6453
+ const refusal = await ssoStore.recordTest(connection.tenantRef, personOf(session), connection.id, pending.version, shown);
6454
+ if (refusal)
6455
+ return identityPage(session, SSO_REFUSALS[refusal].status, SSO_REFUSALS[refusal].text);
6456
+ await audit(session, { action: 'sso.connection_tested', targetType: 'sso_connection', targetRef: connection.id }, connection.tenantRef);
6457
+ return redirect(303, `${root}/settings/identity`, [clear]);
6458
+ }
6459
+ if (pending.purpose === 'proof') {
6460
+ // A session through one team's provider alone proves nothing to another's, nor links to it.
6461
+ if (!session || personOf(session) !== pending.person || session.sc !== undefined)
6462
+ return signedOutPage(400, SIGN_IN_AGAIN, [clear]);
6463
+ const result = await ssoStore.signIn({
6464
+ connectionId: connection.id,
6465
+ issuer: connection.issuer,
6466
+ subject,
6467
+ entraTid: entra?.tid ?? null,
6468
+ entraOid: entra?.oid ?? null,
6469
+ email,
6470
+ emailOk,
6471
+ groups,
6472
+ overage,
6473
+ guest,
6474
+ sessionPerson: personOf(session),
6475
+ memberCap: null,
6476
+ });
6477
+ if (result.refusal)
6478
+ return teamsPage(session, SSO_REFUSALS[result.refusal].status, SSO_REFUSALS[result.refusal].text);
6479
+ // Recorded only while the connection is on as it was: turned off meanwhile, no proof.
6480
+ if (!(await ssoStore.recordProof(session.sid, personOf(session), connection.id, false, connection.configVersion))) {
6481
+ return teamsPage(session, SSO_REFUSALS.connection_inactive.status, SSO_REFUSALS.connection_inactive.text);
6482
+ }
6483
+ // The team's own provider vouched for the address: the directory's user there is this person.
6484
+ if (scimStore && emailOk && email !== null && emailDomain !== null && verifiedDomains.includes(emailDomain)) {
6485
+ await scimStore.bind(personOf(session), [email], plans ? FREE_TEAM_MEMBERS : null);
6486
+ }
6487
+ if (result.outcome === 'linked') {
6488
+ await audit(session, { action: 'sso.linked', targetType: 'sso_connection', targetRef: connection.id }, connection.tenantRef);
6489
+ }
6490
+ return moveTo(session, connection.tenantRef.slice('team:'.length), '', [clear]);
6491
+ }
6492
+ // Through the provider alone.
6493
+ const result = await ssoStore.signIn({
6494
+ connectionId: connection.id,
6495
+ issuer: connection.issuer,
6496
+ subject,
6497
+ entraTid: entra?.tid ?? null,
6498
+ entraOid: entra?.oid ?? null,
6499
+ email,
6500
+ emailOk,
6501
+ groups,
6502
+ overage,
6503
+ guest,
6504
+ sessionPerson: null,
6505
+ memberCap: await memberCapOf(connection.tenantRef),
6506
+ });
6507
+ if (result.refusal || result.person === null) {
6508
+ const refusal = result.refusal ?? 'not_member';
6509
+ return signedOutPage(SSO_REFUSALS[refusal].status, SSO_REFUSALS[refusal].text, [clear]);
6510
+ }
6511
+ const person = result.person;
6512
+ const [idp, uid] = person.startsWith('github:')
6513
+ ? [undefined, person.slice('github:'.length)]
6514
+ : person.startsWith('google:')
6515
+ ? ['google', person.slice('google:'.length)]
6516
+ : ['sso', person.slice('sso:'.length)];
6517
+ if (!allowed(uid, idp))
6518
+ return signedOutPage(403, 'This Slotlock server does not let that account sign in.', [clear]);
6519
+ const epoch = workspaces ? await workspaces.signedIn(person) : undefined;
6520
+ // Named by the provider's email only where the team trusts it: at a domain it verified.
6521
+ const trusted = emailOk && emailDomain !== null && verifiedDomains.includes(emailDomain);
6522
+ if (scimStore && trusted && email !== null)
6523
+ await scimStore.bind(person, [email], plans ? FREE_TEAM_MEMBERS : null);
6524
+ const login = trusted && email !== null ? email : 'SSO account';
6525
+ if (teamStore && idp === 'sso')
6526
+ await teamStore.setDisplay(person, login === 'SSO account' ? null : login);
6527
+ const sid = randomBytes(24).toString('base64url');
6528
+ const expires = now() + SESSION_TTL_MS;
6529
+ await workspaces?.sessions?.started(person, sid, new Date(expires), requestOrigin(request));
6530
+ // Scoped to this team for the session's life: the provider reaches nothing else of the person's.
6531
+ if (!(await ssoStore.recordProof(sid, person, connection.id, true, connection.configVersion))) {
6532
+ await workspaces?.sessions?.end(person, workspaces.sessions.idOf(sid));
6533
+ return signedOutPage(409, SSO_REFUSALS.connection_inactive.text, [clear]);
6534
+ }
6535
+ const sealedSession = seal('slotlock-dashboard-session-v1', {
6536
+ uid,
6537
+ login,
6538
+ sid,
6539
+ exp: expires,
6540
+ ...(idp !== undefined ? { idp } : {}),
6541
+ ...(epoch !== undefined ? { ep: epoch } : {}),
6542
+ team: connection.tenantRef.slice('team:'.length),
6543
+ sc: connection.id,
6544
+ });
6545
+ if (options.resources.audit) {
6546
+ await options.resources.audit
6547
+ .record(connection.tenantRef, {
6548
+ action: result.outcome === 'joined' ? 'member.joined' : 'sso.signed_in',
6549
+ actorType: 'person',
6550
+ actorRef: person,
6551
+ actorRole: result.role,
6552
+ ...requestOrigin(request),
6553
+ ...(overage ? { metadata: { groups: 'overage' } } : {}),
6554
+ })
6555
+ .catch((error) => ignoreRejection(options.onError?.(error)));
6556
+ }
6557
+ return redirect(303, root, [clear, setCookie(SESSION_COOKIE, sealedSession, SESSION_TTL_MS / 1000)]);
6558
+ }
6559
+ /**
6560
+ * Where single sign-on starts without a session: the team's handle, or an address at its verified
6561
+ * domain. A link only names the team; the person continues to its provider by a button here (a
6562
+ * same-origin POST), so another site can neither sign someone in to a team they did not choose
6563
+ * nor send them on to a provider's address through this one.
6564
+ */
6565
+ async function ssoStartPage(url, handle) {
6566
+ if (!ssoStore)
6567
+ return nothingHere404();
6568
+ const email = (url.searchParams.get('email') ?? '').trim();
6569
+ const team = (handle ?? url.searchParams.get('team') ?? '').trim();
6570
+ if (team !== '' || email !== '') {
6571
+ const domain = email === '' ? null : slotlockEmailDomain(email);
6572
+ const route = await ssoStore.route(team !== '' ? { handle: team } : domain !== null ? { domain } : {});
6573
+ if (route) {
6574
+ const provider = new URL(route.authorizationEndpoint).host;
6575
+ const content = html `<section class="sign-in"><span class="sign-in-mark">${ICONS.mark}</span><h1>Sign in to ${route.name}</h1><form method="post" action="${root}/sso/start"><input type="hidden" name="team" value="${route.handle}"><button class="primary wide" type="submit">Continue to ${provider}</button></form><p class="fine"><a href="${root}">Other ways to sign in</a></p></section>`;
6576
+ return respond(200, layout('Single sign-on', content, null));
6577
+ }
6578
+ }
6579
+ const asked = team !== '' || email !== '';
6580
+ const content = html `<section class="sign-in"><span class="sign-in-mark">${ICONS.mark}</span><h1>Single sign-on</h1>${asked ? html `<p class="notice" role="alert">No team signs in that way. Check the address, or ask your team’s owner.</p>` : html ``}<form method="get" action="${root}/sso"><label for="sso-email">Work email</label><input id="sso-email" name="email" type="email" maxlength="254" autocomplete="email" required><button class="primary wide" type="submit">Continue</button></form><p class="fine"><a href="${root}">Other ways to sign in</a></p></section>`;
6581
+ return respond(asked ? 404 : 200, layout('Single sign-on', content, null));
6582
+ }
6583
+ /** Send an invitation's link where a sender is set: whether it went. The link is shown either way. */
6584
+ async function deliverInvitation(session, to, role, link, expiresAt) {
6585
+ if (!options.invitations)
6586
+ return false;
6587
+ try {
6588
+ await options.invitations.send({
6589
+ to,
6590
+ workspace: currentTeam(session)?.name ?? 'a team',
6591
+ inviter: shownAs(session),
6592
+ role: SLOTLOCK_WORKSPACE_ROLE_NAMES[role],
6593
+ link,
6594
+ expiresAt,
6595
+ });
6596
+ return true;
6597
+ }
6598
+ catch (error) {
6599
+ // The sender's own message names the provider and its status, never the address.
6600
+ const known = error instanceof Error && error.name === 'SlotlockInvitationEmailError';
6601
+ report(`Invitation email failed: ${known ? error.message : error instanceof Error ? error.name : 'unknown error'}`);
6602
+ return false;
6603
+ }
6604
+ }
6605
+ async function inviteMember(session, form) {
6606
+ const tenantRef = teamTenantOf(session);
6607
+ if (!teamStore || tenantRef === undefined)
6608
+ return nothingHere(session);
6609
+ const email = (form.get('email') ?? '').trim().toLowerCase();
6610
+ const role = form.get('role') ?? '';
6611
+ if (email.length > 254 || !EMAIL_ADDRESS.test(email)) {
6612
+ return settingsPage(session, 400, 'Enter an email address.', undefined, 'members');
6613
+ }
6614
+ if (!isSlotlockWorkspaceRole(role))
6615
+ return settingsPage(session, 400, 'Choose a role.', undefined, 'members');
6616
+ const raw = randomBytes(32).toString('base64url');
6617
+ const sent = await teamStore.invite({
6618
+ tenantRef,
6619
+ inviter: personOf(session),
6620
+ to: { email },
6621
+ role,
6622
+ tokenSha256: tokenDigest(raw),
6623
+ days: INVITATION_DAYS,
6624
+ dailyLimit: INVITATION_DAILY_LIMIT,
6625
+ memberCap: await memberCapOf(tenantRef),
6626
+ });
6627
+ if ('refusal' in sent)
6628
+ return refusalPage(session, sent.refusal);
6629
+ await audit(session, { action: 'member.invited', targetType: 'invitation', targetRef: sent.id, metadata: { role } }, tenantRef);
6630
+ const link = `${publicRoot}/invite?t=${raw}`;
6631
+ const expiresAt = new Date(now() + INVITATION_DAYS * DAY_MS);
6632
+ const delivered = await deliverInvitation(session, email, role, link, expiresAt);
6633
+ return settingsPage(session, 200, delivered ? `Invitation sent to ${email}.` : `Invitation made for ${email}. Send them the link.`, undefined, 'members', { link, to: email, expiresAt });
6634
+ }
6635
+ async function resendInvitation(session, form) {
6636
+ const tenantRef = teamTenantOf(session);
6637
+ if (!teamStore || tenantRef === undefined)
6638
+ return nothingHere(session);
6639
+ const id = (form.get('id') ?? '').toLowerCase();
6640
+ const invitation = (await teamStore.invitations(tenantRef)).find((candidate) => candidate.id === id);
6641
+ if (!invitation)
6642
+ return refusalPage(session, 'not_found');
6643
+ const raw = randomBytes(32).toString('base64url');
6644
+ const refusal = await teamStore.resend(tenantRef, personOf(session), id, tokenDigest(raw), INVITATION_DAYS, INVITATION_DAILY_LIMIT, await memberCapOf(tenantRef));
6645
+ if (refusal)
6646
+ return refusalPage(session, refusal);
6647
+ await audit(session, { action: 'invitation.resent', targetType: 'invitation', targetRef: id }, tenantRef);
6648
+ const to = invitation.email ?? `@${invitation.githubLogin ?? 'GitHub account'}`;
6649
+ const link = `${publicRoot}/invite?t=${raw}`;
6650
+ const expiresAt = new Date(now() + INVITATION_DAYS * DAY_MS);
6651
+ const delivered = invitation.email !== null && (await deliverInvitation(session, invitation.email, invitation.role, link, expiresAt));
6652
+ return settingsPage(session, 200, delivered ? `Invitation sent to ${to} again. The old link no longer works.` : `A new link for ${to}. The old one no longer works.`, undefined, 'members', { link, to, expiresAt });
6653
+ }
6654
+ async function revokeInvitation(session, form) {
6655
+ const tenantRef = teamTenantOf(session);
6656
+ if (!teamStore || tenantRef === undefined)
6657
+ return nothingHere(session);
6658
+ const id = (form.get('id') ?? '').toLowerCase();
6659
+ if (!(await teamStore.revoke(tenantRef, personOf(session), id)))
6660
+ return refusalPage(session, 'not_found');
6661
+ await audit(session, { action: 'invitation.revoked', targetType: 'invitation', targetRef: id }, tenantRef);
6662
+ return settingsPage(session, 200, 'Invitation revoked: its link no longer works.', undefined, 'members');
6663
+ }
6664
+ async function changeMemberRole(session, form) {
6665
+ const tenantRef = teamTenantOf(session);
6666
+ if (!teamStore || tenantRef === undefined)
6667
+ return nothingHere(session);
6668
+ const person = form.get('person') ?? '';
6669
+ const role = form.get('role') ?? '';
6670
+ if (!isSlotlockWorkspaceRole(role))
6671
+ return settingsPage(session, 400, 'Choose a role.', undefined, 'members');
6672
+ const members = await teamStore.members(tenantRef);
6673
+ const member = members.find((candidate) => candidate.person === person);
6674
+ if (!member)
6675
+ return refusalPage(session, 'not_found');
6676
+ if (member.role === role)
6677
+ return settingsPage(session, 200, 'Nothing changed.', undefined, 'members');
6678
+ const refusal = await teamStore.changeRole(tenantRef, personOf(session), person, role);
6679
+ if (refusal)
6680
+ return refusalPage(session, refusal);
6681
+ await audit(session, { action: 'member.role_changed', targetType: 'person', targetRef: person, metadata: { from: member.role, to: role } }, tenantRef);
6682
+ // One's own role changed: the page is read again under the new one.
6683
+ if (person === personOf(session))
6684
+ return redirect(303, `${root}/settings/members`);
6685
+ return settingsPage(session, 200, `${memberName(member)} is now ${SLOTLOCK_WORKSPACE_ROLE_NAMES[role]}.`, undefined, 'members');
6686
+ }
6687
+ /** Revoke the keys a member created in a team's Live and Test environments: how many were. */
6688
+ async function revokeKeysOf(tenantRef, person, actor) {
6689
+ let revoked = 0;
6690
+ for (const tenant of [tenantRef, `${tenantRef}${SLOTLOCK_TEST_TENANT_SUFFIX}`]) {
6691
+ for (const key of await options.keys.list({ tenantRef: tenant })) {
6692
+ if (key.createdBy !== person || key.revokedAt !== null)
6693
+ continue;
6694
+ if (await options.keys.revoke({ tenantRef: tenant, actor, origin: 'dashboard', id: key.id }))
6695
+ revoked += 1;
6696
+ }
6697
+ }
6698
+ return revoked;
6699
+ }
6700
+ /**
6701
+ * Limit a member to locations and groups (RFC 0004 D1), or give them all of the workspace again.
6702
+ * Keys they created reach all of it, so limiting them revokes those unless the form keeps them.
6703
+ */
6704
+ async function limitMember(session, form) {
6705
+ const tenantRef = teamTenantOf(session);
6706
+ const grouping = options.resources.grouping;
6707
+ if (!teamStore || tenantRef === undefined || !grouping)
6708
+ return nothingHere(session);
6709
+ const me = personOf(session);
6710
+ const person = form.get('person') ?? '';
6711
+ const member = (await teamStore.members(tenantRef)).find((candidate) => candidate.person === person);
6712
+ if (!member)
6713
+ return refusalPage(session, 'not_found');
6714
+ let scope = null;
6715
+ if (form.get('access') !== 'all') {
6716
+ const chosen = { locations: [...new Set(form.getAll('locations'))].sort(), groups: [...new Set(form.getAll('groups'))].sort() };
6717
+ if (chosen.locations.length + chosen.groups.length === 0) {
6718
+ return settingsPage(session, 400, 'Choose at least one location or group, or all of the workspace.', undefined, 'members');
6719
+ }
6720
+ if (chosen.locations.length > SLOTLOCK_MAX_KEY_SCOPE_REFS || chosen.groups.length > SLOTLOCK_MAX_KEY_SCOPE_REFS) {
6721
+ return settingsPage(session, 400, `Choose up to ${String(SLOTLOCK_MAX_KEY_SCOPE_REFS)} locations and ${String(SLOTLOCK_MAX_KEY_SCOPE_REFS)} groups.`, undefined, 'members');
6722
+ }
6723
+ // Each one the workspace has.
6724
+ const [locations, groups] = await Promise.all([grouping.locations(tenantRef), grouping.groups(tenantRef)]);
6725
+ const known = (refs, kept) => refs.every((ref) => kept.some((entry) => entry.ref === ref));
6726
+ if (!isSlotlockResourceScope(chosen) || !known(chosen.locations, locations) || !known(chosen.groups, groups)) {
6727
+ return settingsPage(session, 400, 'Choose locations and groups this workspace has.', undefined, 'members');
6728
+ }
6729
+ scope = chosen;
6730
+ }
6731
+ const refusal = await teamStore.setScope(tenantRef, me, person, scope);
6732
+ if (refusal)
6733
+ return refusalPage(session, refusal);
6734
+ const keep = scope === null || form.get('keys') === 'keep';
6735
+ const revoked = keep ? 0 : await revokeKeysOf(tenantRef, person, me);
6736
+ await audit(session, {
6737
+ action: 'member.scope_changed',
6738
+ targetType: 'person',
6739
+ targetRef: person,
6740
+ metadata: scope
6741
+ ? { locations: scope.locations, groups: scope.groups, keys: keep ? 'kept' : 'revoked', revoked }
6742
+ : { reaches: 'all' },
6743
+ }, tenantRef);
6744
+ const name = memberName(member);
6745
+ const team = currentTeam(session)?.name ?? 'this team';
6746
+ return settingsPage(session, 200, scope
6747
+ ? `${name} now reaches only the resources at those locations and in those groups${revoked > 0 ? `, and ${String(revoked)} key${revoked === 1 ? '' : 's'} they created ${revoked === 1 ? 'was' : 'were'} revoked` : ''}.`
6748
+ : `${name} now reaches all of ${team}.`, undefined, 'members');
6749
+ }
6750
+ async function removeMember(session, form) {
6751
+ const tenantRef = teamTenantOf(session);
6752
+ if (!teamStore || tenantRef === undefined)
6753
+ return nothingHere(session);
6754
+ const me = personOf(session);
6755
+ const person = form.get('person') ?? '';
6756
+ if (person === me)
6757
+ return settingsPage(session, 400, 'To leave this team, use Leave.', undefined, 'members');
6758
+ const member = (await teamStore.members(tenantRef)).find((candidate) => candidate.person === person);
6759
+ if (!member)
6760
+ return refusalPage(session, 'not_found');
6761
+ const refusal = await teamStore.remove(tenantRef, me, person);
6762
+ if (refusal)
6763
+ return refusalPage(session, refusal);
6764
+ // Keys they created, Live and Test: revoked unless the form keeps them.
6765
+ const keep = form.get('keys') === 'keep';
6766
+ const revoked = keep ? 0 : await revokeKeysOf(tenantRef, person, me);
6767
+ await audit(session, { action: 'member.removed', targetType: 'person', targetRef: person, metadata: { keys: keep ? 'kept' : 'revoked', revoked } }, tenantRef);
6768
+ return settingsPage(session, 200, `${memberName(member)} was removed${revoked > 0 ? `, and ${String(revoked)} key${revoked === 1 ? '' : 's'} they created revoked` : ''}.`, undefined, 'members');
6769
+ }
6770
+ async function leaveTeam(session) {
6771
+ const tenantRef = teamTenantOf(session);
6772
+ if (!teamStore || tenantRef === undefined)
6773
+ return nothingHere(session);
6774
+ const me = personOf(session);
6775
+ const refusal = await teamStore.remove(tenantRef, me, me);
6776
+ if (refusal)
6777
+ return refusalPage(session, refusal);
6778
+ await audit(session, { action: 'member.left', targetType: 'person', targetRef: me }, tenantRef);
6779
+ return moveTo(session, undefined, '');
6780
+ }
6781
+ /** The invitation link being opened in this browser: its token, or null. */
6782
+ function inviteTokenOf(request) {
6783
+ const opened = unseal('slotlock-dashboard-invite-v1', readCookies(request).get(INVITE_COOKIE));
6784
+ return opened &&
6785
+ typeof opened.t === 'string' &&
6786
+ INVITE_TOKEN.test(opened.t) &&
6787
+ typeof opened.exp === 'number' &&
6788
+ opened.exp > now()
6789
+ ? opened.t
6790
+ : null;
6791
+ }
6792
+ /** An invitation's own page: what it is for, and joining, in the states it can be in. Never consumes it. */
6793
+ async function invitePage(session, raw, status = 200, notice, checked = false) {
6794
+ const preview = raw && teamStore ? await teamStore.preview(tokenDigest(raw)) : null;
6795
+ const headers = { 'Referrer-Policy': 'no-referrer' };
6796
+ if (!preview) {
6797
+ const content = html `${noticeOf(notice, status)}${pageHead('Invitation')}<p>This invitation link can’t be used here. Open the link you were sent again, in this browser.</p>`;
6798
+ return respond(status === 200 ? 404 : status, layout('Invitation', content, session), headers);
6799
+ }
6800
+ const name = preview.workspaceName;
6801
+ const role = SLOTLOCK_WORKSPACE_ROLE_NAMES[preview.role];
6802
+ const forWhom = preview.email !== null ? maskedEmail(preview.email) : `@${preview.githubLogin ?? 'a GitHub account'}`;
6803
+ let body;
6804
+ if (preview.state !== 'pending') {
6805
+ body = html `<p>${preview.state === 'expired'
6806
+ ? `This invitation expired. Ask ${preview.inviterDisplay} to send it again.`
6807
+ : preview.state === 'revoked'
6808
+ ? 'This invitation was withdrawn.'
6809
+ : 'This invitation was already used.'}</p>`;
6810
+ }
6811
+ else if (!session) {
6812
+ const then = new URLSearchParams({ then: '/invite' }).toString();
6813
+ // An invitation by email asks GitHub for the person's verified addresses on the way in.
6814
+ const github = preview.email !== null ? `${then}&email=1` : then;
6815
+ body = html `<p>${preview.inviterDisplay} invited ${forWhom} as ${role}.</p><p class="actions"><a class="button primary" href="${root}/sign-in?${github}">${ICONS.github}Continue with GitHub</a>${google && preview.email !== null
6816
+ ? html ` <a class="button" href="${root}/sign-in?with=google&amp;${then}">${ICONS.google}Continue with Google</a>`
6817
+ : html ``}</p>`;
6818
+ }
6819
+ else if ((teamListOf.get(session) ?? []).some((ws) => ws.tenantRef === preview.tenantRef)) {
6820
+ body = html `<p>You’re already a member of ${name}.</p><form method="post" action="${root}/organization"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="organization" value="${preview.tenantRef}"><button class="primary" type="submit">Open ${name}</button></form>`;
6821
+ }
6822
+ else {
6823
+ const person = personOf(session);
6824
+ const bound = (preview.githubRef !== null && preview.githubRef === person) ||
6825
+ (preview.email !== null && verifiedEmails(session).includes(preview.email));
6826
+ if (bound) {
6827
+ body = html `<p>${preview.inviterDisplay} invited you as ${role}.</p><form method="post" action="${root}/invite/accept"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><button class="primary" type="submit">Join as ${shownAs(session)}</button></form>`;
6828
+ }
6829
+ else if (preview.email !== null && session.idp !== 'google') {
6830
+ // A GitHub account shows its addresses only when asked: once, for this invitation.
6831
+ body = html `<p>${preview.inviterDisplay} invited ${forWhom} as ${role}.${checked ? html ` None of the addresses GitHub verified for ${shownAs(session)} is it.` : html ``}</p><p class="actions"><a class="button primary" href="${root}/sign-in?then=%2Finvite&amp;email=1">${ICONS.github}Confirm the address with GitHub</a></p>`;
6832
+ }
6833
+ else {
6834
+ body = html `<p>This invitation is for ${forWhom}, and you’re signed in as ${shownAs(session)}. Sign out, then open the link again and sign in as that account.</p>`;
6835
+ }
6836
+ }
6837
+ const content = html `${noticeOf(notice, status)}${pageHead(`Join ${name}`)}${body}`;
6838
+ return respond(status, layout(`Join ${name}`, content, session), headers);
6839
+ }
6840
+ async function acceptInvitation(session, form, request) {
6841
+ if (!teamStore)
6842
+ return nothingHere(session);
6843
+ const person = personOf(session);
6844
+ const emails = verifiedEmails(session);
6845
+ const id = (form.get('invitation') ?? '').toLowerCase();
6846
+ let accepted;
6847
+ let role;
6848
+ let raw = null;
6849
+ if (id !== '') {
6850
+ // One offered on Teams: an invitation open to this person, found again here.
6851
+ const offered = (await teamStore.pendingFor(person, emails)).find((invitation) => invitation.id === id);
6852
+ if (!offered)
6853
+ return teamsPage(session, 404, 'That invitation is no longer open to you.');
6854
+ role = offered.role;
6855
+ accepted = await teamStore.accept({ id: offered.id, person, emails, memberCap: await memberCapOf(offered.tenantRef) });
6856
+ }
6857
+ else {
6858
+ raw = inviteTokenOf(request);
6859
+ const preview = raw ? await teamStore.preview(tokenDigest(raw)) : null;
6860
+ if (!raw || !preview)
6861
+ return invitePage(session, null, 400);
6862
+ role = preview.role;
6863
+ accepted = await teamStore.accept({ tokenSha256: tokenDigest(raw), person, emails, memberCap: await memberCapOf(preview.tenantRef) });
6864
+ }
6865
+ if ('refusal' in accepted) {
6866
+ const { status, text } = TEAM_REFUSALS[accepted.refusal];
6867
+ return id !== '' ? teamsPage(session, status, text) : invitePage(session, raw, status, text);
6868
+ }
6869
+ await audit(session, { action: 'member.joined', actorRole: role, targetType: 'person', targetRef: person }, accepted.tenantRef);
6870
+ // Into the team joined; the link is spent, and the addresses kept for it go.
6871
+ return moveTo(session, accepted.tenantRef.slice('team:'.length), '', [setCookie(INVITE_COOKIE, '', 0)], false);
4363
6872
  }
4364
6873
  async function changeTeam(session, form, on) {
4365
6874
  if (!teams)
@@ -4369,6 +6878,10 @@ ${rows.length === 0
4369
6878
  return teamsPage(session, 400, 'That is not one of your organizations.');
4370
6879
  if (org.role !== 'admin')
4371
6880
  return teamsPage(session, 403, `Only an owner of ${org.login} can turn Slotlock ${on ? 'on' : 'off'} for it.`);
6881
+ // And an Owner of its workspace in Slotlock: a GitHub owner Slotlock gave a lesser role may not.
6882
+ if (workspaces && (await workspaces.standing(`github-org:${org.id}`, personOf(session), session.sid)).role !== 'owner') {
6883
+ return teamsPage(session, 403, `Only an owner of ${org.login}'s workspace can turn Slotlock ${on ? 'on' : 'off'} for it.`);
6884
+ }
4372
6885
  // Who owns it was read as the person signed in: only a recent sign-in is trusted to say so.
4373
6886
  if (now() - (session.exp - SESSION_TTL_MS) > FRESH_SIGN_IN_MS) {
4374
6887
  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.`);
@@ -4384,13 +6897,28 @@ ${rows.length === 0
4384
6897
  }
4385
6898
  async function switchOrganization(session, form) {
4386
6899
  const wanted = form.get('organization') ?? '';
6900
+ const teamId = /^team:(.+)$/.exec(wanted)?.[1];
6901
+ if (teamStore && (teamId !== undefined || (wanted === '' && !teams))) {
6902
+ // Their own tenant, or a team workspace they are a member of now.
6903
+ if (teamId !== undefined) {
6904
+ const standing = TEAM_ID.test(teamId) && workspaces ? await workspaces.standing(wanted, personOf(session), session.sid) : null;
6905
+ if (standing?.refusal === 'sso_required') {
6906
+ const name = teamListOf.get(session)?.find((ws) => ws.tenantRef === wanted)?.name ?? 'This team';
6907
+ return ssoRequiredPage(session, wanted, name, standing.mayBypass === true);
6908
+ }
6909
+ if (!standing || standing.role === null) {
6910
+ return respond(400, layout('Refused', html `<p class="notice">You are not a member of that team.</p>`, session));
6911
+ }
6912
+ }
6913
+ return moveTo(session, teamId, '');
6914
+ }
4387
6915
  if (teams) {
4388
6916
  // Their own tenant, or an organization of theirs that is turned on now.
4389
6917
  const org = session.orgs?.find((candidate) => candidate.id === wanted);
4390
6918
  if (wanted !== '' && (!org || !(await teams.enabled([wanted])).has(wanted))) {
4391
6919
  return respond(400, layout('Refused', html `<p class="notice">You cannot act for that organization. An owner turns it on from Teams.</p>`, session));
4392
6920
  }
4393
- const { org: _, ...personal } = session;
6921
+ const { org: _, team: _team, ...personal } = session;
4394
6922
  const sealed = seal('slotlock-dashboard-session-v1', wanted === '' ? personal : { ...personal, org: wanted });
4395
6923
  const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
4396
6924
  return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
@@ -4403,7 +6931,7 @@ ${rows.length === 0
4403
6931
  const left = Math.max(1, Math.floor((session.exp - now()) / 1000));
4404
6932
  return redirect(303, root, [setCookie(SESSION_COOKIE, sealed, left)]);
4405
6933
  }
4406
- async function startSignIn() {
6934
+ async function startSignIn(then, emails = false) {
4407
6935
  const state = randomBytes(32).toString('base64url');
4408
6936
  const verifier = randomBytes(32).toString('base64url');
4409
6937
  const challenge = createHash('sha256').update(verifier).digest('base64url');
@@ -4415,12 +6943,17 @@ ${rows.length === 0
4415
6943
  code_challenge: challenge,
4416
6944
  code_challenge_method: 'S256',
4417
6945
  // Reading organization membership needs read:org; a personal tenant needs no scope at all.
4418
- ...(readsOrganizations ? { scope: 'read:org' } : {}),
6946
+ // Accepting an invitation by email asks GitHub for the person's verified addresses, then only.
6947
+ ...(readsOrganizations || emails
6948
+ ? { scope: [...(readsOrganizations ? ['read:org'] : []), ...(emails ? ['user:email'] : [])].join(' ') }
6949
+ : {}),
4419
6950
  }).toString();
4420
6951
  const sealed = seal('slotlock-dashboard-sign-in-v1', {
4421
6952
  state,
4422
6953
  verifier,
4423
6954
  exp: now() + SIGN_IN_TTL_MS,
6955
+ ...(then ? { then } : {}),
6956
+ ...(emails ? { emails: true } : {}),
4424
6957
  });
4425
6958
  return redirect(302, target.href, [setCookie(SIGN_IN_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
4426
6959
  }
@@ -4482,6 +7015,30 @@ ${rows.length === 0
4482
7015
  }
4483
7016
  return { uid: String(body.id), login: body.login };
4484
7017
  }
7018
+ /** The addresses GitHub verified for the token's person, lower-cased; none when it does not say. */
7019
+ async function readGitHubEmails(token) {
7020
+ const response = await fetchImpl(`${userUrl}/emails`, {
7021
+ headers: {
7022
+ Authorization: `Bearer ${token}`,
7023
+ Accept: 'application/vnd.github+json',
7024
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
7025
+ 'User-Agent': USER_AGENT,
7026
+ },
7027
+ redirect: 'manual',
7028
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
7029
+ });
7030
+ if (!response.ok) {
7031
+ report(`GitHub email lookup failed: HTTP ${response.status}`);
7032
+ return [];
7033
+ }
7034
+ const body = await response.json().catch(() => null);
7035
+ if (!Array.isArray(body))
7036
+ return [];
7037
+ return body
7038
+ .slice(0, 100)
7039
+ .filter((entry) => isRecord(entry) && entry.verified === true && typeof entry.email === 'string' && entry.email.length <= 254)
7040
+ .map((entry) => entry.email.toLowerCase());
7041
+ }
4485
7042
  /**
4486
7043
  * The served organizations the token's person is an active member of, by login, or null when
4487
7044
  * GitHub does not answer. Only `state: active` counts; at most MAX_SESSION_ORGANIZATIONS.
@@ -4530,7 +7087,7 @@ ${rows.length === 0
4530
7087
  .slice(0, MAX_SESSION_ORGANIZATIONS);
4531
7088
  }
4532
7089
  /** Google's sign-in (OpenID Connect, code flow with PKCE and a nonce), back to the same callback. */
4533
- async function startGoogleSignIn(provider) {
7090
+ async function startGoogleSignIn(provider, then) {
4534
7091
  const state = randomBytes(32).toString('base64url');
4535
7092
  const verifier = randomBytes(32).toString('base64url');
4536
7093
  const nonce = randomBytes(24).toString('base64url');
@@ -4551,54 +7108,12 @@ ${rows.length === 0
4551
7108
  nonce,
4552
7109
  idp: 'google',
4553
7110
  exp: now() + SIGN_IN_TTL_MS,
7111
+ ...(then ? { then } : {}),
4554
7112
  });
4555
7113
  return redirect(302, target.href, [setCookie(SIGN_IN_COOKIE, sealed, SIGN_IN_TTL_MS / 1000)]);
4556
7114
  }
4557
- /** Google's signing keys by key id, kept for as long as its answer says (an hour by default). */
4558
- let googleKeys = null;
4559
- async function readGoogleKeys(provider, refresh) {
4560
- if (googleKeys && !refresh && googleKeys.until > now())
4561
- return googleKeys.keys;
4562
- const response = await fetchImpl(provider.jwksUrl ?? 'https://www.googleapis.com/oauth2/v3/certs', {
4563
- headers: { Accept: 'application/json' },
4564
- redirect: 'manual',
4565
- signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
4566
- });
4567
- if (!response.ok)
4568
- throw new Error(`Google keys answered HTTP ${response.status}`);
4569
- const body = await response.json();
4570
- const keys = new Map();
4571
- for (const jwk of isRecord(body) && Array.isArray(body.keys) ? body.keys : []) {
4572
- if (!isRecord(jwk) || jwk.kty !== 'RSA' || typeof jwk.kid !== 'string' || (jwk.alg !== undefined && jwk.alg !== 'RS256'))
4573
- continue;
4574
- const key = await globalThis.crypto.subtle
4575
- .importKey('jwk', { kty: 'RSA', n: jwk.n, e: jwk.e, alg: 'RS256', ext: true }, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'])
4576
- .catch(() => null);
4577
- if (key)
4578
- keys.set(jwk.kid, key);
4579
- }
4580
- const maxAge = Number(/max-age=(\d+)/.exec(response.headers.get('cache-control') ?? '')?.[1]);
4581
- googleKeys = { keys, until: now() + (Number.isFinite(maxAge) && maxAge > 0 ? Math.min(maxAge * 1000, 86_400_000) : GOOGLE_KEYS_DEFAULT_TTL_MS) };
4582
- return keys;
4583
- }
4584
- /** 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). */
4585
- async function googleSigned(provider, header, payload, signature) {
4586
- let parsed;
4587
- try {
4588
- parsed = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
4589
- }
4590
- catch {
4591
- return false;
4592
- }
4593
- if (!isRecord(parsed) || parsed.alg !== 'RS256' || typeof parsed.kid !== 'string')
4594
- return false;
4595
- let key = (await readGoogleKeys(provider, false)).get(parsed.kid);
4596
- if (!key)
4597
- key = (await readGoogleKeys(provider, true)).get(parsed.kid);
4598
- if (!key)
4599
- return false;
4600
- return globalThis.crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, Buffer.from(signature, 'base64url'), Buffer.from(`${header}.${payload}`, 'ascii'));
4601
- }
7115
+ /** Issuers' signing keys (Google's, and teams' identity providers'), by JWKS URL (oidc.ts). */
7116
+ const jwks = createSlotlockJwksCache({ fetch: fetchImpl, now });
4602
7117
  /**
4603
7118
  * The person Google signed in, from the ID token its token endpoint returned for this code. The
4604
7119
  * token's signature is checked against Google's published keys, and its issuer, audience (and
@@ -4626,48 +7141,45 @@ ${rows.length === 0
4626
7141
  }
4627
7142
  const body = await response.json().catch(() => null);
4628
7143
  const token = isRecord(body) && typeof body.id_token === 'string' ? body.id_token : null;
4629
- const [header, payload, signature, ...extra] = token?.split('.') ?? [];
4630
- if (!header || !payload || !signature || extra.length > 0 || ![header, payload, signature].every((part) => BASE64URL.test(part))) {
7144
+ if (token === null)
4631
7145
  return null;
4632
- }
4633
- // Signed by Google, with one of the keys it publishes (RS256), whichever way the token came.
4634
- if (!(await googleSigned(provider, header, payload, signature))) {
4635
- report('Google sign-in refused: the ID token is not signed by Google');
7146
+ // Signed by Google with a key it publishes (RS256), for this client, unexpired, with this
7147
+ // sign-in's nonce (oidc.ts verifySlotlockIdToken: OpenID Connect Core 1.0, 3.1.3.7).
7148
+ const checked = await verifySlotlockIdToken(token, {
7149
+ issuers: SLOTLOCK_GOOGLE_ISSUERS,
7150
+ clientId: provider.clientId,
7151
+ algorithms: ['RS256'],
7152
+ keys: jwks.lookup(provider.jwksUrl ?? 'https://www.googleapis.com/oauth2/v3/certs'),
7153
+ nonce,
7154
+ now: now(),
7155
+ });
7156
+ if ('refusal' in checked) {
7157
+ report(`Google sign-in refused: the ID token did not check out (${checked.refusal})`);
4636
7158
  return null;
4637
7159
  }
4638
- const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
4639
- if (!isRecord(claims))
4640
- return null;
4641
- const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
4642
- if ((claims.iss !== 'https://accounts.google.com' && claims.iss !== 'accounts.google.com') ||
4643
- !audience.includes(provider.clientId) ||
4644
- (audience.length > 1 && claims.azp !== provider.clientId) ||
4645
- typeof claims.exp !== 'number' ||
4646
- claims.exp * 1000 <= now() ||
4647
- typeof claims.nonce !== 'string' ||
4648
- !safeEqual(claims.nonce, nonce) ||
4649
- typeof claims.sub !== 'string' ||
4650
- !GOOGLE_SUBJECT.test(claims.sub)) {
4651
- report('Google sign-in refused: the ID token did not check out');
7160
+ const claims = checked.claims;
7161
+ if (typeof claims.sub !== 'string' || !GOOGLE_SUBJECT.test(claims.sub))
4652
7162
  return null;
4653
- }
4654
7163
  // The email names the person on the page only, and only once Google has verified it.
4655
- const email = claims.email_verified === true && typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email)
7164
+ const email = (claims.email_verified === true || claims.email_verified === 'true') && typeof claims.email === 'string' && claims.email.length <= 254 && !/[<>\p{Cc}]/u.test(claims.email)
4656
7165
  ? claims.email
4657
7166
  : 'Google account';
4658
- return { uid: claims.sub, login: email, idp: 'google' };
7167
+ // A Google Workspace account names its domain (hd); a consumer account names none.
7168
+ const hd = typeof claims.hd === 'string' && /^[a-z0-9.-]{1,253}$/i.test(claims.hd) ? claims.hd.toLowerCase() : undefined;
7169
+ return { uid: claims.sub, login: email, idp: 'google', ...(hd !== undefined && email.includes('@') ? { hd } : {}) };
4659
7170
  }
4660
7171
  catch (error) {
4661
7172
  report(`Google sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`);
4662
7173
  return null;
4663
7174
  }
4664
7175
  }
4665
- async function askGitHub(code, verifier) {
7176
+ async function askGitHub(code, verifier, emails = false) {
4666
7177
  try {
4667
7178
  const token = await exchangeCode(code, verifier);
4668
7179
  if (token === null)
4669
7180
  return null;
4670
- const user = await readGitHubUser(token);
7181
+ const found = await readGitHubUser(token);
7182
+ const user = found && emails ? { ...found, emails: await readGitHubEmails(token) } : found;
4671
7183
  if (!user || !readsOrganizations)
4672
7184
  return user;
4673
7185
  const orgs = await readMemberships(token);
@@ -4715,7 +7227,7 @@ ${rows.length === 0
4715
7227
  ? google && typeof pending.nonce === 'string'
4716
7228
  ? await askGoogle(google, code, verifier, pending.nonce)
4717
7229
  : null
4718
- : await askGitHub(code, verifier);
7230
+ : await askGitHub(code, verifier, pending.emails === true);
4719
7231
  }
4720
7232
  finally {
4721
7233
  signInsInFlight -= 1;
@@ -4737,15 +7249,64 @@ ${rows.length === 0
4737
7249
  if (served && orgs.length === 0) {
4738
7250
  return signedOutPage(403, `@${user.login} is not a member of an organization this Slotlock server serves.`, [clearSignIn]);
4739
7251
  }
7252
+ // The person and, with GitHub, the organizations GitHub lists them in, as memberships now; the
7253
+ // epoch their sessions must carry.
7254
+ const person = user.idp === 'google' ? `google:${user.uid}` : `github:${user.uid}`;
7255
+ const epoch = workspaces
7256
+ ? await workspaces.signedIn(person, user.idp === 'google' || user.orgs === undefined
7257
+ ? undefined
7258
+ : user.orgs.map((org) => ({ id: org.id, role: org.role === 'admin' ? 'admin' : 'member' })))
7259
+ : undefined;
7260
+ // How the person is shown to the others in their teams: their GitHub login, or Google's verified email.
7261
+ if (teamStore) {
7262
+ await teamStore.setDisplay(person, user.idp === 'google' ? (user.login.includes('@') ? user.login : null) : `@${user.login}`);
7263
+ }
7264
+ // Of the addresses GitHub verified, only the invitation being opened's is kept, to accept it.
7265
+ let em;
7266
+ if (teamStore && user.emails) {
7267
+ const raw = inviteTokenOf(request);
7268
+ const invited = raw ? (await teamStore.preview(tokenDigest(raw)))?.email : null;
7269
+ if (invited && user.emails.includes(invited))
7270
+ em = [invited];
7271
+ else if (domainStore && pending.then === '/teams' && pending.emails === true) {
7272
+ // Looking for a team at one's domain: only the addresses at a team one may ask to join.
7273
+ const keys = new Set((await domainStore.discoverable(person, user.emails)).map((found) => found.domainKey));
7274
+ const kept = user.emails.filter((email) => keys.has(slotlockEmailDomain(email) ?? '')).slice(0, 3);
7275
+ if (kept.length > 0)
7276
+ em = kept;
7277
+ }
7278
+ }
7279
+ // A Google Workspace account of its own domain binds the directory's user at that address (SCIM):
7280
+ // the domain vouches for it. An address GitHub or a consumer Google account verified once does not.
7281
+ if (scimStore && user.idp === 'google' && user.hd !== undefined && user.login.includes('@')) {
7282
+ const address = user.login.toLowerCase();
7283
+ if (slotlockEmailDomain(address) === slotlockEmailDomain(`x@${user.hd}`))
7284
+ await scimStore.bind(person, [address], plans ? FREE_TEAM_MEMBERS : null);
7285
+ }
7286
+ const sealedOrg = served ? orgs[0].id : undefined;
7287
+ const sid = randomBytes(24).toString('base64url');
7288
+ const expires = now() + SESSION_TTL_MS;
7289
+ // Listed on the person's Account page, to sign out from another device.
7290
+ await workspaces?.sessions?.started(person, sid, new Date(expires), requestOrigin(request));
4740
7291
  const session = seal('slotlock-dashboard-session-v1', {
4741
7292
  uid: user.uid,
4742
7293
  login: user.login,
4743
- sid: randomBytes(24).toString('base64url'),
4744
- exp: now() + SESSION_TTL_MS,
4745
- ...(user.idp === 'google' ? { idp: 'google' } : {}),
4746
- ...(served ? { orgs, org: orgs[0].id } : teams && user.idp !== 'google' ? { orgs } : {}),
7294
+ sid,
7295
+ exp: expires,
7296
+ ...(user.idp === 'google' ? { idp: 'google', ...(user.hd !== undefined ? { hd: user.hd } : {}) } : {}),
7297
+ ...(served ? { orgs, org: sealedOrg } : teams && user.idp !== 'google' ? { orgs } : {}),
7298
+ ...(epoch !== undefined ? { ep: epoch } : {}),
7299
+ ...(em ? { em } : {}),
4747
7300
  });
4748
- return redirect(303, root, [
7301
+ if (options.resources.audit) {
7302
+ const signedInTo = sealedOrg !== undefined ? `github-org:${sealedOrg}` : person;
7303
+ const from = requestOrigin(request);
7304
+ await options.resources.audit
7305
+ .record(signedInTo, { action: 'session.signed_in', actorType: 'person', actorRef: person, ...from })
7306
+ .catch((error) => ignoreRejection(options.onError?.(error)));
7307
+ }
7308
+ const back = typeof pending.then === 'string' && STEP_UP_PAGES.has(pending.then) ? pending.then : '';
7309
+ return redirect(303, `${root}${back}${(back === '/invite' || back === '/teams') && pending.emails === true ? '?checked=1' : ''}`, [
4749
7310
  clearSignIn,
4750
7311
  setCookie(SESSION_COOKIE, session, SESSION_TTL_MS / 1000),
4751
7312
  ]);
@@ -4844,7 +7405,7 @@ ${rows.length === 0
4844
7405
  }
4845
7406
  if (code === 'api_key_limit_reached') {
4846
7407
  return keysPage(session, 409, plans
4847
- ? 'You have as many active keys as your plan allows. Revoke one first, or see Usage.'
7408
+ ? html `You have as many active keys as your plan allows. Revoke one first, or ${await pastLimit(session)}.`
4848
7409
  : 'You have as many keys as Slotlock allows. Revoke one first.');
4849
7410
  }
4850
7411
  throw error;
@@ -4919,7 +7480,7 @@ ${rows.length === 0
4919
7480
  if (code === 'resource_limit_reached') {
4920
7481
  const limit = error.limit;
4921
7482
  return resourcesPage(session, 409, typeof limit === 'number' && limit < SLOTLOCK_DASHBOARD_MAX_RESOURCES
4922
- ? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Usage.`
7483
+ ? html `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or ${await pastLimit(session)}.`
4923
7484
  : `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
4924
7485
  }
4925
7486
  if (code === 'invalid_timezone') {
@@ -4937,6 +7498,13 @@ ${rows.length === 0
4937
7498
  if (request.headers.get('origin') !== origin) {
4938
7499
  return respond(403, layout('Refused', html `<p class="notice">That request did not come from this dashboard.</p>`, null));
4939
7500
  }
7501
+ // Single sign-on continues to the team's provider from its own page, with or without a session.
7502
+ if (route === '/sso/start' && ssoStore) {
7503
+ const form = await readForm(request);
7504
+ const handle = form === null || form === 'too_large' ? '' : (form.get('team') ?? '');
7505
+ const found = /^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$/.test(handle) ? await ssoStore.route({ handle }) : null;
7506
+ return found ? startSsoSignIn(found, 'signin') : ssoStartPage(new URL(`${publicRoot}/sso?team=${encodeURIComponent(handle)}`));
7507
+ }
4940
7508
  const session = await readSession(request);
4941
7509
  if (!session)
4942
7510
  return redirect(303, root);
@@ -4960,6 +7528,7 @@ ${rows.length === 0
4960
7528
  if (!POST_ROUTES.has(route)) {
4961
7529
  return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
4962
7530
  }
7531
+ origins.set(session, requestOrigin(request));
4963
7532
  // A form sent again (a reload of its result, a double click) must not create or rotate twice.
4964
7533
  // Every form but sign-out carries a single-use value, so leaving it out cannot skip this check
4965
7534
  // or the per-person form limit.
@@ -4970,7 +7539,7 @@ ${rows.length === 0
4970
7539
  if (once !== null) {
4971
7540
  const checked = checkFormNonce(session, once);
4972
7541
  if (checked === 'other_org') {
4973
- return pageFor(route)(session, 409, 'That page was for another organization. Nothing was changed: check the organization and try again.');
7542
+ return pageOrNotice(route)(session, 409, 'That page was for another workspace. Nothing was changed: check the workspace and try again.');
4974
7543
  }
4975
7544
  let first;
4976
7545
  try {
@@ -4981,7 +7550,7 @@ ${rows.length === 0
4981
7550
  catch (error) {
4982
7551
  if (error.code !== 'dashboard_form_limit')
4983
7552
  throw error;
4984
- return pageFor(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
7553
+ return pageOrNotice(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
4985
7554
  }
4986
7555
  if (!first) {
4987
7556
  // The first one was taken: a second click, a reload or a resend shows what it did.
@@ -4989,17 +7558,41 @@ ${rows.length === 0
4989
7558
  const chat = chatOf(form.get('chat'));
4990
7559
  return redirect(303, chat ? `${chatUrl(chat)}#latest` : `${root}/assistant#latest`);
4991
7560
  }
4992
- return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
7561
+ return pageOrNotice(route)(session, 409, 'That form was already sent. Reload the page to start again.');
4993
7562
  }
4994
7563
  }
7564
+ // What the person may do, from their role now: before anything changes, and after the form's
7565
+ // single-use value and the per-person form limit, so refusals cannot flood the audit log.
7566
+ const refused = await authorizePost(session, route, form);
7567
+ if (refused)
7568
+ return refused;
4995
7569
  // A tenant new to plans is put on the default plan before it adds what plans limit.
4996
7570
  if (route === '/keys' || route === '/resources' || route === '/webhooks') {
4997
7571
  await plans?.enrol?.(tenantOf(session));
4998
7572
  }
7573
+ const response = await dispatchPost(session, route, form, request);
7574
+ const action = AUDITED_ROUTES[route];
7575
+ if (action && response.status < 400)
7576
+ await audit(session, { action, ...auditDetails(route, form) });
7577
+ return response;
7578
+ }
7579
+ async function dispatchPost(session, route, form, request) {
4999
7580
  switch (route) {
7581
+ case '/sign-out-everywhere':
7582
+ // Every session of the person, on every device and server, this one included: only with
7583
+ // the membership store, which holds the epoch every session carries.
7584
+ if (!workspaces)
7585
+ return notFound(session);
7586
+ await workspaces.signOutEverywhere(personOf(session));
7587
+ await workspaces.sessions?.end(personOf(session), null);
7588
+ await options.state.endSession(session.sid, session.exp);
7589
+ return redirect(303, root, [setCookie(SESSION_COOKIE, '', 0)]);
5000
7590
  case '/sign-out':
5001
7591
  await options.state.endSession(session.sid, session.exp);
7592
+ await workspaces?.sessions?.end(personOf(session), workspaces.sessions.idOf(session.sid));
5002
7593
  return redirect(303, root, [setCookie(SESSION_COOKIE, '', 0)]);
7594
+ case '/sessions/end':
7595
+ return endSession(session, form);
5003
7596
  case '/keys':
5004
7597
  return createKey(session, form);
5005
7598
  case '/keys/rotate':
@@ -5022,6 +7615,16 @@ ${rows.length === 0
5022
7615
  return deleteHolidayCalendar(session, form);
5023
7616
  case '/contacts/forget':
5024
7617
  return forgetContact(session, form);
7618
+ case '/settings/privacy/export':
7619
+ return askPrivacyExport(session);
7620
+ case '/settings/privacy/export/delete':
7621
+ return deletePrivacyExport(session, form);
7622
+ case '/settings/privacy/person':
7623
+ return findPerson(session, form);
7624
+ case '/settings/privacy/person/export':
7625
+ return exportPerson(session, form);
7626
+ case '/settings/privacy/person/erase':
7627
+ return erasePerson(session, form);
5025
7628
  case '/organization':
5026
7629
  return switchOrganization(session, form);
5027
7630
  case '/environment':
@@ -5090,6 +7693,70 @@ ${rows.length === 0
5090
7693
  return changeTeam(session, form, true);
5091
7694
  case '/teams/disable':
5092
7695
  return changeTeam(session, form, false);
7696
+ case '/teams/create':
7697
+ return createTeam(session, form);
7698
+ case '/teams/delete':
7699
+ return deleteTeam(session, form);
7700
+ case '/teams/restore':
7701
+ return restoreTeam(session);
7702
+ case '/invite/accept':
7703
+ return acceptInvitation(session, form, request);
7704
+ case '/members/invite':
7705
+ return inviteMember(session, form);
7706
+ case '/members/resend':
7707
+ return resendInvitation(session, form);
7708
+ case '/members/revoke':
7709
+ return revokeInvitation(session, form);
7710
+ case '/members/role':
7711
+ return changeMemberRole(session, form);
7712
+ case '/members/scope':
7713
+ return limitMember(session, form);
7714
+ case '/members/remove':
7715
+ return removeMember(session, form);
7716
+ case '/members/leave':
7717
+ return leaveTeam(session);
7718
+ case '/members/requests':
7719
+ return decideJoinRequest(session, form);
7720
+ case '/join/request':
7721
+ return requestToJoin(session, form);
7722
+ case '/identity/domains':
7723
+ return claimDomain(session, form);
7724
+ case '/identity/domains/verify':
7725
+ return verifyDomain(session, form);
7726
+ case '/identity/domains/remove':
7727
+ return releaseDomain(session, form);
7728
+ case '/identity/domains/policy':
7729
+ return changeJoinPolicy(session, form);
7730
+ case '/identity/invites':
7731
+ return restrictInvites(session, form);
7732
+ case '/identity/sso':
7733
+ return saveSsoConnection(session, form);
7734
+ case '/identity/sso/test':
7735
+ return testSsoConnection(session);
7736
+ case '/identity/sso/status':
7737
+ return setSsoStatus(session, form);
7738
+ case '/identity/sso/delete':
7739
+ return deleteSsoConnection(session, form);
7740
+ case '/identity/sso/provisioning':
7741
+ return setSsoProvisioning(session, form);
7742
+ case '/identity/sso/mapping':
7743
+ return setSsoMapping(session, form);
7744
+ case '/identity/sso/require':
7745
+ return setRequireSso(session, form);
7746
+ case '/sso/proof':
7747
+ return startSsoProof(session, form);
7748
+ case '/sso/bypass':
7749
+ return useSsoBypass(session, form);
7750
+ case '/identity/scim/enable':
7751
+ return enableScim(session, form);
7752
+ case '/identity/scim/disable':
7753
+ return disableScim(session);
7754
+ case '/identity/scim/token':
7755
+ return createScimToken(session);
7756
+ case '/identity/scim/token/revoke':
7757
+ return revokeScimToken(session, form);
7758
+ case '/identity/scim/group':
7759
+ return mapScimGroup(session, form);
5093
7760
  default:
5094
7761
  return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
5095
7762
  }
@@ -5099,126 +7766,220 @@ ${rows.length === 0
5099
7766
  const { pathname } = new URL(request.url);
5100
7767
  return pathname === root || pathname.startsWith(`${root}/`);
5101
7768
  },
5102
- async fetch(request) {
5103
- const url = new URL(request.url);
5104
- const route = url.pathname.slice(root.length).replace(/\/$/, '') || '/';
5105
- try {
5106
- if (request.method === 'GET') {
5107
- switch (route) {
5108
- case '/': {
5109
- const session = await readSession(request);
5110
- return session ? await overviewPage(session) : signedOutPage(200);
7769
+ // Each request with its own scope (dashboard-scope.ts), set once the person's standing is read.
7770
+ fetch: (request) => runSlotlockDashboardRequest(() => serve(request)),
7771
+ };
7772
+ async function serve(request) {
7773
+ const url = new URL(request.url);
7774
+ const route = url.pathname.slice(root.length).replace(/\/$/, '') || '/';
7775
+ try {
7776
+ // A team's SCIM endpoint: its identity provider's, by bearer token, never a session or a form.
7777
+ if (scimStore && route.startsWith('/scim/v2/')) {
7778
+ const log = options.resources.audit;
7779
+ return await handleSlotlockScimRequest(request, route.slice('/scim/v2/'.length), {
7780
+ scim: scimStore,
7781
+ publicRoot,
7782
+ documentationUri: 'https://slotlock.pylota.io/docs/dashboard/#scim',
7783
+ ...(log ? { record: (tenantRef, event) => log.record(tenantRef, event) } : {}),
7784
+ // Their keys go as the team chose when it turned SCIM on.
7785
+ deactivated: (tenantRef, person, keyPolicy) => (keyPolicy === 'revoke' ? revokeKeysOf(tenantRef, person, 'scim') : Promise.resolve(0)),
7786
+ });
7787
+ }
7788
+ if (request.method === 'GET') {
7789
+ switch (route) {
7790
+ case '/': {
7791
+ const session = await readSession(request);
7792
+ if (!session)
7793
+ return signedOutPage(200);
7794
+ return authorizePage(session, '/') ?? (await overviewPage(session));
7795
+ }
7796
+ case '/keys':
7797
+ case '/resources':
7798
+ case '/webhooks':
7799
+ case '/waitlist':
7800
+ case '/approvals':
7801
+ case '/assistant':
7802
+ case '/assistant/chats':
7803
+ case '/plan':
7804
+ case '/teams':
7805
+ case '/settings/endpoints':
7806
+ case '/settings/hours':
7807
+ case '/settings/account':
7808
+ case '/settings/members':
7809
+ case '/settings/audit':
7810
+ case '/settings/audit/export':
7811
+ case '/settings/team':
7812
+ case '/settings/privacy':
7813
+ case '/settings/privacy/download':
7814
+ case '/settings/identity':
7815
+ case '/settings': {
7816
+ const session = await readSession(request);
7817
+ if (!session)
7818
+ return redirect(303, root);
7819
+ const denied = authorizePage(session, route);
7820
+ if (denied)
7821
+ return denied;
7822
+ if (route === '/teams')
7823
+ return await teamsPage(session, 200, undefined, url.searchParams.get('checked') === '1');
7824
+ if (route === '/plan') {
7825
+ return await planPage(session, 200, url.searchParams.get('checkout') === 'done'
7826
+ ? 'Thank you. Your plan changes here once Stripe confirms the payment, usually within a minute.'
7827
+ : undefined, url.searchParams.get('checkout') === 'done');
5111
7828
  }
5112
- case '/keys':
5113
- case '/resources':
5114
- case '/webhooks':
5115
- case '/waitlist':
5116
- case '/approvals':
5117
- case '/assistant':
5118
- case '/assistant/chats':
5119
- case '/plan':
5120
- case '/teams':
5121
- case '/settings/endpoints':
5122
- case '/settings/hours':
5123
- case '/settings/account':
5124
- case '/settings': {
5125
- const session = await readSession(request);
5126
- if (!session)
5127
- return redirect(303, root);
5128
- if (route === '/teams')
5129
- return await teamsPage(session);
5130
- if (route === '/plan') {
5131
- return await planPage(session, 200, url.searchParams.get('checkout') === 'done'
5132
- ? 'Thank you. Your plan changes here once Stripe confirms the payment, usually within a minute.'
5133
- : undefined, url.searchParams.get('checkout') === 'done');
5134
- }
5135
- if (route === '/keys')
5136
- return await keysPage(session);
5137
- if (route === '/resources') {
5138
- return await resourcesPage(session, 200, undefined, url.searchParams.get('hours') === 'own');
5139
- }
5140
- if (route === '/webhooks')
5141
- return await webhooksPage(session);
5142
- if (route === '/waitlist')
5143
- return await waitlistPage(session);
5144
- if (route === '/approvals')
5145
- return await approvalsPage(session);
5146
- if (route === '/assistant')
5147
- return await assistantPage(session, 200, undefined, url.searchParams.get('c') ?? undefined);
5148
- if (route === '/assistant/chats')
5149
- return await chatsPage(session);
5150
- if (route === '/settings/endpoints')
5151
- return await settingsPage(session, 200, undefined, undefined, 'endpoints');
5152
- if (route === '/settings/hours')
5153
- return await settingsPage(session, 200, undefined, undefined, 'hours');
5154
- if (route === '/settings/account')
5155
- return await settingsPage(session, 200, undefined, undefined, 'account');
5156
- // A provider preset fills the assistant's form; nothing is saved until Save.
5157
- const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
5158
- return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
7829
+ if (route === '/keys')
7830
+ return await keysPage(session);
7831
+ if (route === '/resources') {
7832
+ return await resourcesPage(session, 200, undefined, url.searchParams.get('hours') === 'own');
5159
7833
  }
5160
- case '/style.css':
5161
- return asset(DASHBOARD_ASSETS.css, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
5162
- case '/app.js':
5163
- return asset(DASHBOARD_ASSETS.js, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
5164
- case '/favicon.svg':
5165
- return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
5166
- case '/sign-in':
5167
- return google && url.searchParams.get('with') === 'google'
5168
- ? await startGoogleSignIn(google)
5169
- : await startSignIn();
5170
- case '/callback':
5171
- return await finishSignIn(request, url);
5172
- case '/hours': {
5173
- const session = await readSession(request);
5174
- return session ? await hoursEditor(session, url) : redirect(303, root);
7834
+ if (route === '/webhooks')
7835
+ return await webhooksPage(session);
7836
+ if (route === '/waitlist')
7837
+ return await waitlistPage(session);
7838
+ if (route === '/approvals')
7839
+ return await approvalsPage(session);
7840
+ if (route === '/assistant')
7841
+ return await assistantPage(session, 200, undefined, url.searchParams.get('c') ?? undefined);
7842
+ if (route === '/assistant/chats')
7843
+ return await chatsPage(session);
7844
+ if (route === '/settings/endpoints')
7845
+ return await settingsPage(session, 200, undefined, undefined, 'endpoints');
7846
+ if (route === '/settings/hours')
7847
+ return await settingsPage(session, 200, undefined, undefined, 'hours');
7848
+ if (route === '/settings/account')
7849
+ return await settingsPage(session, 200, undefined, undefined, 'account');
7850
+ if (route === '/settings/members') {
7851
+ return teamStore && session.team !== undefined
7852
+ ? await settingsPage(session, 200, undefined, undefined, 'members')
7853
+ : nothingHere(session);
5175
7854
  }
5176
- case '/closures': {
5177
- const session = await readSession(request);
5178
- return session ? await closuresPage(session) : redirect(303, root);
7855
+ if (route === '/settings/audit/export')
7856
+ return await exportAudit(session, url.searchParams);
7857
+ if (route === '/settings/privacy') {
7858
+ return options.resources.privacy
7859
+ ? await settingsPage(session, 200, undefined, undefined, 'privacy')
7860
+ : nothingHere(session);
5179
7861
  }
5180
- case '/contacts': {
5181
- const session = await readSession(request);
5182
- const after = url.searchParams.get('after') ?? undefined;
5183
- return session ? await contactsPage(session, 200, undefined, after) : redirect(303, root);
7862
+ if (route === '/settings/privacy/download')
7863
+ return await downloadPrivacyExport(session, url.searchParams);
7864
+ if (route === '/settings/team') {
7865
+ return teamStore && session.team !== undefined
7866
+ ? await settingsPage(session, 200, undefined, undefined, 'team')
7867
+ : nothingHere(session);
5184
7868
  }
5185
- case '/locations': {
5186
- const session = await readSession(request);
5187
- return session ? await locationsPage(session) : redirect(303, root);
7869
+ if (route === '/settings/identity') {
7870
+ return domainStore && session.team !== undefined
7871
+ ? await settingsPage(session, 200, undefined, undefined, 'identity')
7872
+ : nothingHere(session);
7873
+ }
7874
+ if (route === '/settings/audit') {
7875
+ return options.resources.audit
7876
+ ? await settingsPage(session, 200, undefined, undefined, 'audit', undefined, url.searchParams)
7877
+ : nothingHere(session);
5188
7878
  }
5189
- default: {
5190
- const resourceId = RESOURCE_PAGE.exec(route)?.[1];
5191
- if (resourceId !== undefined) {
5192
- const session = await readSession(request);
5193
- return session
5194
- ? await resourceDetailPage(session, resourceId.toLowerCase())
5195
- : redirect(303, root);
5196
- }
5197
- return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, null));
7879
+ // A provider preset fills the assistant's form; nothing is saved until Save.
7880
+ const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
7881
+ return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
7882
+ }
7883
+ case '/style.css':
7884
+ return asset(DASHBOARD_ASSETS.css, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
7885
+ case '/app.js':
7886
+ return asset(DASHBOARD_ASSETS.js, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
7887
+ case '/favicon.svg':
7888
+ return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
7889
+ case '/sign-in': {
7890
+ // After signing in again to confirm it's you, back to the page that asked.
7891
+ const then = url.searchParams.get('then') ?? '';
7892
+ const back = STEP_UP_PAGES.has(then) ? then : undefined;
7893
+ return google && url.searchParams.get('with') === 'google'
7894
+ ? await startGoogleSignIn(google, back)
7895
+ : await startSignIn(back, Boolean(teamStore) && (back === '/invite' || (back === '/teams' && Boolean(domainStore))) && url.searchParams.get('email') === '1');
7896
+ }
7897
+ case '/invite': {
7898
+ if (!teamStore)
7899
+ return nothingHere404();
7900
+ // An invitation's link: its token (in the query, which request logs leave out) is kept
7901
+ // in this browser for the sign-in that accepts it, and nothing else; opening it twice
7902
+ // (a link scanner, a preview) consumes nothing.
7903
+ const raw = url.searchParams.get('t');
7904
+ if (raw !== null) {
7905
+ if (!INVITE_TOKEN.test(raw))
7906
+ return nothingHere404();
7907
+ const opened = seal('slotlock-dashboard-invite-v1', { t: raw, exp: now() + INVITE_LINK_TTL_MS });
7908
+ return respond(303, '', { Location: `${root}/invite`, 'Referrer-Policy': 'no-referrer' }, [
7909
+ setCookie(INVITE_COOKIE, opened, INVITE_LINK_TTL_MS / 1000),
7910
+ ]);
5198
7911
  }
7912
+ const session = await readSession(request);
7913
+ return await invitePage(session, inviteTokenOf(request), 200, undefined, url.searchParams.get('checked') === '1');
7914
+ }
7915
+ case '/callback':
7916
+ return await finishSignIn(request, url);
7917
+ case '/sso':
7918
+ return await ssoStartPage(url);
7919
+ case '/hours': {
7920
+ const session = await readSession(request);
7921
+ if (!session)
7922
+ return redirect(303, root);
7923
+ return authorizePage(session, route) ?? (await hoursEditor(session, url));
7924
+ }
7925
+ case '/closures': {
7926
+ const session = await readSession(request);
7927
+ if (!session)
7928
+ return redirect(303, root);
7929
+ return authorizePage(session, route) ?? (await closuresPage(session));
7930
+ }
7931
+ case '/contacts': {
7932
+ const session = await readSession(request);
7933
+ const after = url.searchParams.get('after') ?? undefined;
7934
+ if (!session)
7935
+ return redirect(303, root);
7936
+ return authorizePage(session, route) ?? (await contactsPage(session, 200, undefined, after));
7937
+ }
7938
+ case '/locations': {
7939
+ const session = await readSession(request);
7940
+ if (!session)
7941
+ return redirect(303, root);
7942
+ return authorizePage(session, route) ?? (await locationsPage(session));
7943
+ }
7944
+ default: {
7945
+ // A team's provider's callback, by connection; and single sign-on by a team's handle.
7946
+ const ssoCallback = /^\/sso\/callback\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.exec(route)?.[1];
7947
+ if (ssoCallback !== undefined && ssoStore)
7948
+ return await finishSsoSignIn(request, url, ssoCallback);
7949
+ const ssoHandle = /^\/sso\/([a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?)$/.exec(route)?.[1];
7950
+ if (ssoHandle !== undefined && ssoStore)
7951
+ return await ssoStartPage(url, ssoHandle);
7952
+ const resourceId = RESOURCE_PAGE.exec(route)?.[1];
7953
+ if (resourceId !== undefined) {
7954
+ const session = await readSession(request);
7955
+ if (!session)
7956
+ return redirect(303, root);
7957
+ return authorizePage(session, '/resources/:id') ?? (await resourceDetailPage(session, resourceId.toLowerCase()));
7958
+ }
7959
+ return respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, null));
5199
7960
  }
5200
7961
  }
5201
- if (request.method === 'POST')
5202
- return await handlePost(request, route);
5203
- return respond(405, layout('Not allowed', html `<p class="notice">Use the dashboard's pages and forms.</p>`, null), {
5204
- Allow: 'GET, POST',
5205
- });
5206
7962
  }
5207
- catch (error) {
5208
- // A suspended tenant may look, not change: the store refuses its writes.
5209
- if (error?.code === 'tenant_suspended') {
5210
- return respond(403, layout('Suspended', html `<p class="notice">This account is suspended: its data can be seen, not changed.</p>`, null));
5211
- }
5212
- try {
5213
- ignoreRejection(options.onError?.(error));
5214
- }
5215
- catch {
5216
- // A failing reporter must not change the response.
5217
- }
5218
- return respond(500, layout('Error', html `<p class="notice">Something went wrong. Try again in a moment.</p>`, null));
7963
+ if (request.method === 'POST')
7964
+ return await handlePost(request, route);
7965
+ return respond(405, layout('Not allowed', html `<p class="notice">Use the dashboard's pages and forms.</p>`, null), {
7966
+ Allow: 'GET, POST',
7967
+ });
7968
+ }
7969
+ catch (error) {
7970
+ // A suspended tenant may look, not change: the store refuses its writes.
7971
+ if (error?.code === 'tenant_suspended') {
7972
+ return respond(403, layout('Suspended', html `<p class="notice">This account is suspended: its data can be seen, not changed.</p>`, null));
5219
7973
  }
5220
- },
5221
- };
7974
+ try {
7975
+ ignoreRejection(options.onError?.(error));
7976
+ }
7977
+ catch {
7978
+ // A failing reporter must not change the response.
7979
+ }
7980
+ return respond(500, layout('Error', html `<p class="notice">Something went wrong. Try again in a moment.</p>`, null));
7981
+ }
7982
+ }
5222
7983
  }
5223
7984
  // Progressive enhancement only, loaded as a file so the policy needs no inline code: copy buttons
5224
7985
  // (announced in the page's live region), confirmations that close with Escape or a click outside
@@ -5496,6 +8257,29 @@ form .choice { margin: var(--space-1) 0 var(--space-3); }
5496
8257
  .fine + .table, .fine + .empty { margin-top: var(--space-4); }
5497
8258
  .pop label { margin-bottom: var(--space-3); }
5498
8259
  .pop textarea { width: 100%; }
8260
+ /* A team being deleted */
8261
+ .deleting-banner { background: var(--bad-soft, var(--surface-2)); }
8262
+ /* Sessions (Settings, Account) */
8263
+ h2.sessions-head { margin-top: var(--space-6); }
8264
+ /* Audit log */
8265
+ form.audit-filter { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-2) var(--space-3); margin: 0 0 var(--space-4); }
8266
+ form.audit-filter label { flex-basis: 100%; margin: 0; }
8267
+ form.audit-filter select { width: auto; }
8268
+ dl.audit-details { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); margin: 0; font-size: var(--text-sm); }
8269
+ dl.audit-details dt { color: var(--ink-2); }
8270
+ dl.audit-details dd { margin: 0; overflow-wrap: anywhere; }
8271
+ .pager, .audit-tools { display: flex; flex-wrap: wrap; gap: var(--space-4); margin-top: var(--space-4); }
8272
+ .audit-tools { margin: 0 0 var(--space-4); }
8273
+ /* Teams: one-line forms that create a team or invite to one */
8274
+ form.create-team, form.invite { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-2) var(--space-3); margin: var(--space-5) 0; }
8275
+ form.create-team label, form.invite label { flex-basis: 100%; margin: 0; }
8276
+ form.create-team input, form.invite input { flex: 1 1 240px; min-width: 0; }
8277
+ form.invite select { flex: 0 1 auto; width: auto; }
8278
+ .team-workspaces .table + .table { margin-top: var(--space-4); }
8279
+ .seats { margin: var(--space-4) 0; color: var(--ink-2); }
8280
+ .pop fieldset { border: 0; padding: 0; margin: 0 0 var(--space-3); }
8281
+ .pop legend { font-weight: 500; padding: 0; margin-bottom: var(--space-2); }
8282
+ .pop fieldset label { display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-1); font-weight: 400; }
5499
8283
 
5500
8284
  /* Buttons */
5501
8285
  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); }