@agent-native/scheduling 0.1.23 → 0.1.25

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 (75) hide show
  1. package/README.md +16 -0
  2. package/agent-native.package.json +154 -0
  3. package/dist/actions/_helpers.d.ts +10 -0
  4. package/dist/actions/_helpers.d.ts.map +1 -1
  5. package/dist/actions/_helpers.js +14 -0
  6. package/dist/actions/_helpers.js.map +1 -1
  7. package/dist/actions/add-booking-attendee.js +2 -0
  8. package/dist/actions/add-booking-attendee.js.map +1 -1
  9. package/dist/actions/add-booking-note.js +2 -1
  10. package/dist/actions/add-booking-note.js.map +1 -1
  11. package/dist/actions/confirm-booking.js +5 -0
  12. package/dist/actions/confirm-booking.js.map +1 -1
  13. package/dist/actions/duplicate-event-type.js +2 -0
  14. package/dist/actions/duplicate-event-type.js.map +1 -1
  15. package/dist/actions/get-booking.d.ts +3 -1
  16. package/dist/actions/get-booking.js +8 -1
  17. package/dist/actions/get-booking.js.map +1 -1
  18. package/dist/actions/mark-no-show.js +6 -0
  19. package/dist/actions/mark-no-show.js.map +1 -1
  20. package/dist/actions/remove-booking-attendee.js +2 -0
  21. package/dist/actions/remove-booking-attendee.js.map +1 -1
  22. package/dist/actions/revoke-private-link.js +18 -1
  23. package/dist/actions/revoke-private-link.js.map +1 -1
  24. package/dist/actions/send-reschedule-link.js +2 -0
  25. package/dist/actions/send-reschedule-link.js.map +1 -1
  26. package/dist/manifest.d.ts +5 -1
  27. package/dist/manifest.d.ts.map +1 -1
  28. package/dist/manifest.js +6 -1
  29. package/dist/manifest.js.map +1 -1
  30. package/dist/server/availability-engine.d.ts +20 -0
  31. package/dist/server/availability-engine.d.ts.map +1 -1
  32. package/dist/server/availability-engine.js +73 -23
  33. package/dist/server/availability-engine.js.map +1 -1
  34. package/dist/server/booking-service.d.ts.map +1 -1
  35. package/dist/server/booking-service.js +16 -5
  36. package/dist/server/booking-service.js.map +1 -1
  37. package/dist/server/bookings-repo.d.ts.map +1 -1
  38. package/dist/server/bookings-repo.js +50 -48
  39. package/dist/server/bookings-repo.js.map +1 -1
  40. package/dist/server/providers/index.d.ts +2 -0
  41. package/dist/server/providers/index.d.ts.map +1 -1
  42. package/dist/server/providers/index.js +1 -0
  43. package/dist/server/providers/index.js.map +1 -1
  44. package/dist/server/providers/teams.d.ts +22 -0
  45. package/dist/server/providers/teams.d.ts.map +1 -0
  46. package/dist/server/providers/teams.js +112 -0
  47. package/dist/server/providers/teams.js.map +1 -0
  48. package/docs/eject.md +11 -10
  49. package/docs/llms-full.txt +75 -14
  50. package/docs/llms.txt +15 -1
  51. package/docs/providers.md +30 -2
  52. package/docs/skills/integrations/SKILL.md +19 -1
  53. package/package.json +7 -6
  54. package/src/actions/_helpers.ts +18 -0
  55. package/src/actions/add-booking-attendee.ts +2 -0
  56. package/src/actions/add-booking-note.ts +2 -1
  57. package/src/actions/booking-authz.spec.ts +288 -0
  58. package/src/actions/confirm-booking.ts +4 -0
  59. package/src/actions/duplicate-event-type.ts +2 -0
  60. package/src/actions/event-type-authz.spec.ts +209 -0
  61. package/src/actions/get-booking.ts +7 -1
  62. package/src/actions/mark-no-show.ts +5 -0
  63. package/src/actions/remove-booking-attendee.ts +2 -0
  64. package/src/actions/revoke-private-link.ts +16 -1
  65. package/src/actions/send-reschedule-link.ts +2 -0
  66. package/src/manifest.test.ts +19 -0
  67. package/src/manifest.ts +9 -7
  68. package/src/server/availability-engine.test.ts +344 -0
  69. package/src/server/availability-engine.ts +100 -22
  70. package/src/server/booking-service.spec.ts +557 -0
  71. package/src/server/booking-service.ts +17 -6
  72. package/src/server/bookings-repo.ts +54 -48
  73. package/src/server/providers/index.ts +2 -0
  74. package/src/server/providers/teams.test.ts +246 -0
  75. package/src/server/providers/teams.ts +183 -0
@@ -162,19 +162,20 @@ owned by or shared with the current user, read/write actions throw.
162
162
 
163
163
  For full customization, you can move the package source into your own repo.
164
164
 
165
- **Planned for v0.2:** `agent-native eject @agent-native/scheduling`.
165
+ Preview the complete change report, then apply it explicitly:
166
166
 
167
- **Today (v0.1)** — do it manually:
167
+ ```bash
168
+ agent-native package eject @agent-native/scheduling
169
+ agent-native package eject @agent-native/scheduling --apply
170
+ ```
168
171
 
169
- 1. `cp -r node_modules/@agent-native/scheduling/src packages/scheduling-local/src`
170
- 2. Add `packages/scheduling-local/` to your workspaces.
171
- 3. Replace `"@agent-native/scheduling": "^0.1"` in dependencies with
172
- `"@local/scheduling": "workspace:*"` (or similar).
173
- 4. Run a find/replace across your repo from `@agent-native/scheduling` to
174
- `@local/scheduling`.
175
- 5. Install: `pnpm install`.
172
+ The command copies the published source into `packages/scheduling`, updates the
173
+ consumer dependency to `workspace:*`, preserves canonical
174
+ `@agent-native/scheduling` imports, and runs the detected package manager. It
175
+ refuses an existing target, unsupported workspace layout, or other collision.
176
+ All writes and lockfiles roll back if installation fails.
176
177
 
177
- Now you own the code and can modify freely. Upstream updates are available via
178
+ Now you own the code and can modify it freely. Upstream updates are available via
178
179
  `npm view @agent-native/scheduling versions` — you can selectively port changes.
179
180
 
180
181
 
@@ -200,7 +201,7 @@ locally-owned scheduling stack, not an API wrapper.
200
201
  application, booking-limit bucketing, recurring-event expansion.
201
202
  - **Server layer** (`/server`) — DB repos, availability engine, booking
202
203
  service, pluggable calendar + video providers (Google Calendar, Outlook,
203
- Zoom, Google Meet, built-in video), iCal generator, lifecycle hooks →
204
+ Zoom, Microsoft Teams, Google Meet, built-in video), iCal generator, lifecycle hooks →
204
205
  workflows.
205
206
  - **Actions** (`/actions/*`) — ~55 `defineAction` modules covering every
206
207
  operation in the product (event type CRUD, availability edit, booking
@@ -228,6 +229,7 @@ import {
228
229
  registerVideoProvider,
229
230
  createGoogleCalendarProvider,
230
231
  createDailyVideoProvider,
232
+ createTeamsProvider,
231
233
  } from "@agent-native/scheduling/server/providers";
232
234
  import { getDb, schema } from "./db/index.js";
233
235
 
@@ -248,6 +250,12 @@ registerCalendarProvider(createGoogleCalendarProvider({
248
250
  registerVideoProvider(createDailyVideoProvider({
249
251
  apiKey: process.env.DAILY_API_KEY!,
250
252
  }));
253
+
254
+ registerVideoProvider(createTeamsProvider({
255
+ clientId: process.env.MICROSOFT_CLIENT_ID!,
256
+ clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
257
+ getAccessToken: fetchMicrosoftToken, // your refresh-aware implementation
258
+ }));
251
259
  ```
252
260
 
253
261
  ## Full documentation
@@ -255,6 +263,13 @@ registerVideoProvider(createDailyVideoProvider({
255
263
  For the full bundled docs (every .md file concatenated), see
256
264
  `llms-full.txt` in this folder.
257
265
 
266
+ ## Package lifecycle
267
+
268
+ The published static `agent-native.package.json` drives safe package lifecycle
269
+ commands. Use `agent-native package inspect @agent-native/scheduling --json`
270
+ for contribution metadata, and preview `package add` or `package eject` before
271
+ re-running with `--apply`. Inspection never executes package code.
272
+
258
273
  ## Individual skills
259
274
 
260
275
  - skills/scheduling-basics/SKILL.md — Core concepts, terminology.
@@ -282,8 +297,8 @@ Scheduling needs two kinds of providers:
282
297
  - **CalendarProvider** — reads busy intervals + writes events.
283
298
  - **VideoProvider** — creates meeting rooms when a booking is confirmed.
284
299
 
285
- Built-ins: Google Calendar, Office 365, Zoom, built-in video (Daily.co), Google
286
- Meet (piggy-backs on Google Calendar).
300
+ Built-ins: Google Calendar, Office 365, Zoom, Microsoft Teams, built-in video
301
+ (Daily.co), and Google Meet (piggy-backs on Google Calendar).
287
302
 
288
303
  ## CalendarProvider
289
304
 
@@ -344,6 +359,34 @@ Video providers are invoked by the booking service when a booking's location
344
359
  is `builtin-video`, `zoom`, `google-meet`, or `teams`. Meeting URLs land in
345
360
  `booking_references`.
346
361
 
362
+ ### Microsoft Teams
363
+
364
+ Teams uses delegated Microsoft OAuth for work or school accounts. Register the
365
+ provider with consumer-owned token storage and refresh callbacks:
366
+
367
+ ```ts
368
+ import {
369
+ createTeamsProvider,
370
+ registerVideoProvider,
371
+ } from "@agent-native/scheduling/server/providers";
372
+
373
+ registerVideoProvider(
374
+ createTeamsProvider({
375
+ clientId: process.env.MICROSOFT_CLIENT_ID!,
376
+ clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
377
+ tenant: process.env.MICROSOFT_TENANT_ID,
378
+ getAccessToken: resolveMicrosoftAccessToken,
379
+ updateTokens: saveMicrosoftTokens,
380
+ markInvalid: markSchedulingCredentialInvalid,
381
+ }),
382
+ );
383
+ ```
384
+
385
+ The provider requests `offline_access`, `OnlineMeetings.ReadWrite`, and
386
+ `User.Read`. Start the grant with `connect-video --kind teams_video` and finish
387
+ the callback with `completeVideoOAuth()`. `getAccessToken` must refresh expired
388
+ tokens; the package never reads a token store or environment directly.
389
+
347
390
 
348
391
  ---
349
392
  ## schema.md
@@ -710,7 +753,8 @@ description: Calendar + video provider integrations — Google Calendar, Office
710
753
  - **builtin_video** — Daily.co-backed; zero OAuth; server-to-server API key.
711
754
  - **zoom_video** — OAuth; create meetings via Zoom REST API.
712
755
  - **google_meet** — piggy-backs on Google Calendar credential.
713
- - **teams_video** (planned) — Microsoft Teams.
756
+ - **teams_video** — delegated Microsoft OAuth for work or school accounts;
757
+ creates and cancels standalone meetings through Microsoft Graph.
714
758
 
715
759
  ## Credential lifecycle
716
760
 
@@ -742,8 +786,25 @@ See `docs/providers.md` for the full interface.
742
786
  | "Connect Google Calendar" | `connect-calendar --kind google_calendar --redirectUri ...` → redirect to returned `authUrl` |
743
787
  | "Stop checking my vacation calendar" | `toggle-selected-calendar --include false` for that externalId |
744
788
  | "Default to Zoom for new bookings" | `set-default-conferencing-app --credentialId <zoom-cred>` |
789
+ | "Connect Microsoft Teams" | `connect-video --kind teams_video --redirectUri ...` → complete the callback with `completeVideoOAuth()` |
790
+ | "Default to Teams for new bookings" | `set-default-conferencing-app --credentialId <teams-cred>` |
745
791
  | "Refresh calendar cache" | `refresh-busy-times` |
746
792
 
793
+ ## Microsoft Teams setup
794
+
795
+ Register `createTeamsProvider()` as a video provider with the deployment's
796
+ Microsoft client id and secret plus consumer-owned `getAccessToken`,
797
+ `updateTokens`, and `markInvalid` callbacks. The provider defaults to the
798
+ `organizations` tenant because Graph's delegated online-meeting API supports
799
+ work or school accounts, not personal Microsoft accounts. It requests only
800
+ `offline_access`, `OnlineMeetings.ReadWrite`, and `User.Read`.
801
+
802
+ Use `connect-video` to start OAuth and `completeVideoOAuth()` in the callback;
803
+ inserting a `teams_video` row with `install-conferencing-app` alone does not
804
+ grant Microsoft access. Token refresh remains the consumer's responsibility in
805
+ `getAccessToken`. The booking service stores the returned meeting id and join
806
+ URL in `booking_references` and calls the provider again on cancellation.
807
+
747
808
 
748
809
  ---
749
810
  ## skills/routing-forms/SKILL.md
package/docs/llms.txt CHANGED
@@ -17,7 +17,7 @@ locally-owned scheduling stack, not an API wrapper.
17
17
  application, booking-limit bucketing, recurring-event expansion.
18
18
  - **Server layer** (`/server`) — DB repos, availability engine, booking
19
19
  service, pluggable calendar + video providers (Google Calendar, Outlook,
20
- Zoom, Google Meet, built-in video), iCal generator, lifecycle hooks →
20
+ Zoom, Microsoft Teams, Google Meet, built-in video), iCal generator, lifecycle hooks →
21
21
  workflows.
22
22
  - **Actions** (`/actions/*`) — ~55 `defineAction` modules covering every
23
23
  operation in the product (event type CRUD, availability edit, booking
@@ -45,6 +45,7 @@ import {
45
45
  registerVideoProvider,
46
46
  createGoogleCalendarProvider,
47
47
  createDailyVideoProvider,
48
+ createTeamsProvider,
48
49
  } from "@agent-native/scheduling/server/providers";
49
50
  import { getDb, schema } from "./db/index.js";
50
51
 
@@ -65,6 +66,12 @@ registerCalendarProvider(createGoogleCalendarProvider({
65
66
  registerVideoProvider(createDailyVideoProvider({
66
67
  apiKey: process.env.DAILY_API_KEY!,
67
68
  }));
69
+
70
+ registerVideoProvider(createTeamsProvider({
71
+ clientId: process.env.MICROSOFT_CLIENT_ID!,
72
+ clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
73
+ getAccessToken: fetchMicrosoftToken, // your refresh-aware implementation
74
+ }));
68
75
  ```
69
76
 
70
77
  ## Full documentation
@@ -72,6 +79,13 @@ registerVideoProvider(createDailyVideoProvider({
72
79
  For the full bundled docs (every .md file concatenated), see
73
80
  `llms-full.txt` in this folder.
74
81
 
82
+ ## Package lifecycle
83
+
84
+ The published static `agent-native.package.json` drives safe package lifecycle
85
+ commands. Use `agent-native package inspect @agent-native/scheduling --json`
86
+ for contribution metadata, and preview `package add` or `package eject` before
87
+ re-running with `--apply`. Inspection never executes package code.
88
+
75
89
  ## Individual skills
76
90
 
77
91
  - skills/scheduling-basics/SKILL.md — Core concepts, terminology.
package/docs/providers.md CHANGED
@@ -5,8 +5,8 @@ Scheduling needs two kinds of providers:
5
5
  - **CalendarProvider** — reads busy intervals + writes events.
6
6
  - **VideoProvider** — creates meeting rooms when a booking is confirmed.
7
7
 
8
- Built-ins: Google Calendar, Office 365, Zoom, built-in video (Daily.co), Google
9
- Meet (piggy-backs on Google Calendar).
8
+ Built-ins: Google Calendar, Office 365, Zoom, Microsoft Teams, built-in video
9
+ (Daily.co), and Google Meet (piggy-backs on Google Calendar).
10
10
 
11
11
  ## CalendarProvider
12
12
 
@@ -66,3 +66,31 @@ registerVideoProvider({
66
66
  Video providers are invoked by the booking service when a booking's location
67
67
  is `builtin-video`, `zoom`, `google-meet`, or `teams`. Meeting URLs land in
68
68
  `booking_references`.
69
+
70
+ ### Microsoft Teams
71
+
72
+ Teams uses delegated Microsoft OAuth for work or school accounts. Register the
73
+ provider with consumer-owned token storage and refresh callbacks:
74
+
75
+ ```ts
76
+ import {
77
+ createTeamsProvider,
78
+ registerVideoProvider,
79
+ } from "@agent-native/scheduling/server/providers";
80
+
81
+ registerVideoProvider(
82
+ createTeamsProvider({
83
+ clientId: process.env.MICROSOFT_CLIENT_ID!,
84
+ clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
85
+ tenant: process.env.MICROSOFT_TENANT_ID,
86
+ getAccessToken: resolveMicrosoftAccessToken,
87
+ updateTokens: saveMicrosoftTokens,
88
+ markInvalid: markSchedulingCredentialInvalid,
89
+ }),
90
+ );
91
+ ```
92
+
93
+ The provider requests `offline_access`, `OnlineMeetings.ReadWrite`, and
94
+ `User.Read`. Start the grant with `connect-video --kind teams_video` and finish
95
+ the callback with `completeVideoOAuth()`. `getAccessToken` must refresh expired
96
+ tokens; the package never reads a token store or environment directly.
@@ -17,7 +17,8 @@ description: Calendar + video provider integrations — Google Calendar, Office
17
17
  - **builtin_video** — Daily.co-backed; zero OAuth; server-to-server API key.
18
18
  - **zoom_video** — OAuth; create meetings via Zoom REST API.
19
19
  - **google_meet** — piggy-backs on Google Calendar credential.
20
- - **teams_video** (planned) — Microsoft Teams.
20
+ - **teams_video** — delegated Microsoft OAuth for work or school accounts;
21
+ creates and cancels standalone meetings through Microsoft Graph.
21
22
 
22
23
  ## Credential lifecycle
23
24
 
@@ -49,4 +50,21 @@ See `docs/providers.md` for the full interface.
49
50
  | "Connect Google Calendar" | `connect-calendar --kind google_calendar --redirectUri ...` → redirect to returned `authUrl` |
50
51
  | "Stop checking my vacation calendar" | `toggle-selected-calendar --include false` for that externalId |
51
52
  | "Default to Zoom for new bookings" | `set-default-conferencing-app --credentialId <zoom-cred>` |
53
+ | "Connect Microsoft Teams" | `connect-video --kind teams_video --redirectUri ...` → complete the callback with `completeVideoOAuth()` |
54
+ | "Default to Teams for new bookings" | `set-default-conferencing-app --credentialId <teams-cred>` |
52
55
  | "Refresh calendar cache" | `refresh-busy-times` |
56
+
57
+ ## Microsoft Teams setup
58
+
59
+ Register `createTeamsProvider()` as a video provider with the deployment's
60
+ Microsoft client id and secret plus consumer-owned `getAccessToken`,
61
+ `updateTokens`, and `markInvalid` callbacks. The provider defaults to the
62
+ `organizations` tenant because Graph's delegated online-meeting API supports
63
+ work or school accounts, not personal Microsoft accounts. It requests only
64
+ `offline_access`, `OnlineMeetings.ReadWrite`, and `User.Read`.
65
+
66
+ Use `connect-video` to start OAuth and `completeVideoOAuth()` in the callback;
67
+ inserting a `teams_video` row with `install-conferencing-app` alone does not
68
+ grant Microsoft access. Token refresh remains the consumer's responsibility in
69
+ `getAccessToken`. The booking service stores the returned meeting id and join
70
+ URL in `booking_references` and calls the provider again on cancellation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/scheduling",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Scheduling primitives for agent-native apps — event types, availability, bookings, team scheduling, workflows, and routing forms.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -13,6 +13,7 @@
13
13
  "directory": "packages/scheduling"
14
14
  },
15
15
  "files": [
16
+ "agent-native.package.json",
16
17
  "dist",
17
18
  "docs",
18
19
  "src"
@@ -52,23 +53,22 @@
52
53
  "nanoid": "^5.1.9",
53
54
  "rrule": "^2.8.1",
54
55
  "zod": "^4.3.6",
55
- "@agent-native/toolkit": "^0.4.4"
56
+ "@agent-native/toolkit": "^0.4.7"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@libsql/client": "^0.15.8",
59
- "@tabler/icons-react": "^3.0.0",
60
+ "@tabler/icons-react": "^3.41.1",
60
61
  "@types/node": "^24.2.1",
61
62
  "@types/react": "^19.2.14",
62
63
  "drizzle-orm": "^0.45.2",
63
64
  "react": "^19.2.5",
64
- "typescript": "npm:@typescript/typescript6@^6.0.2",
65
65
  "typescript-7": "npm:typescript@^7.0.2",
66
66
  "vitest": "^4.1.5",
67
- "@agent-native/core": "0.92.4"
67
+ "@agent-native/core": "0.98.0"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@agent-native/core": ">=0.8.0",
71
- "@tabler/icons-react": ">=3",
71
+ "@tabler/icons-react": "^3.41.1",
72
72
  "drizzle-orm": ">=0.45",
73
73
  "react": ">=18",
74
74
  "react-dom": ">=18"
@@ -84,6 +84,7 @@
84
84
  "optional": true
85
85
  }
86
86
  },
87
+ "agentNativeManifest": "agent-native.package.json",
87
88
  "scripts": {
88
89
  "build": "tsc && node scripts/build-llms-full.mjs",
89
90
  "dev": "tsc --watch",
@@ -15,6 +15,24 @@ export function currentUserEmailOrNull(): string | null {
15
15
  return getSchedulingContext().getCurrentUserEmail() ?? null;
16
16
  }
17
17
 
18
+ /**
19
+ * Verify the current user is the host of a booking. Throws "Not authorized to
20
+ * <action>" if not. Mirrors the `isHost` half of the `isHost || hasToken`
21
+ * guard used by cancel-booking/reschedule-booking, for booking actions that
22
+ * don't accept a public capability token (attendee/note/status mutations —
23
+ * host identity is the only legitimate caller for these).
24
+ */
25
+ export function assertBookingHost(
26
+ booking: { hostEmail: string },
27
+ action: string,
28
+ ): void {
29
+ const userEmail = currentUserEmailOrNull();
30
+ const isHost = !!userEmail && userEmail === booking.hostEmail;
31
+ if (!isHost) {
32
+ throw new Error(`Not authorized to ${action}`);
33
+ }
34
+ }
35
+
18
36
  export function currentOrgId(): string | undefined {
19
37
  return getSchedulingContext().getCurrentOrgId?.();
20
38
  }
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
 
5
5
  import { getBookingByUid } from "../server/bookings-repo.js";
6
6
  import { getSchedulingContext } from "../server/context.js";
7
+ import { assertBookingHost } from "./_helpers.js";
7
8
 
8
9
  export default defineAction({
9
10
  description: "Add an attendee (or seat reservation) to a booking",
@@ -16,6 +17,7 @@ export default defineAction({
16
17
  run: async (args) => {
17
18
  const booking = await getBookingByUid(args.uid);
18
19
  if (!booking) throw new Error(`Booking ${args.uid} not found`);
20
+ assertBookingHost(booking, "add an attendee to this booking");
19
21
  const { getDb, schema } = getSchedulingContext();
20
22
  await getDb()
21
23
  .insert(schema.bookingAttendees)
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
 
5
5
  import { getBookingByUid } from "../server/bookings-repo.js";
6
6
  import { getSchedulingContext } from "../server/context.js";
7
- import { currentUserEmail } from "./_helpers.js";
7
+ import { assertBookingHost, currentUserEmail } from "./_helpers.js";
8
8
 
9
9
  export default defineAction({
10
10
  description: "Add an internal (host-only) note to a booking",
@@ -12,6 +12,7 @@ export default defineAction({
12
12
  run: async (args) => {
13
13
  const booking = await getBookingByUid(args.uid);
14
14
  if (!booking) throw new Error(`Booking ${args.uid} not found`);
15
+ assertBookingHost(booking, "add a note to this booking");
15
16
  const { getDb, schema } = getSchedulingContext();
16
17
  const id = nanoid();
17
18
  await getDb().insert(schema.bookingNotes).values({
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Cross-tenant write (IDOR) regression tests for booking actions that
3
+ * mutate — or disclose a capability token for — a booking that belongs to
4
+ * another host. Mirrors the `isHost` guard already proven in
5
+ * `cancel-booking.ts` / `reschedule-booking.ts`.
6
+ */
7
+ import { randomUUID } from "node:crypto";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { createClient, type Client } from "@libsql/client";
13
+ import { drizzle } from "drizzle-orm/libsql";
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+
16
+ import * as schema from "../schema/index.js";
17
+ import { setSchedulingContext } from "../server/context.js";
18
+ import addBookingAttendee from "./add-booking-attendee.js";
19
+ import addBookingNote from "./add-booking-note.js";
20
+ import confirmBooking from "./confirm-booking.js";
21
+ import markNoShow from "./mark-no-show.js";
22
+ import removeBookingAttendee from "./remove-booking-attendee.js";
23
+ import sendRescheduleLink from "./send-reschedule-link.js";
24
+
25
+ const HOST_EMAIL = "host@example.com";
26
+ const OUTSIDER_EMAIL = "outsider@example.com";
27
+ const BOOKING_UID = "booking-uid-1";
28
+ const ATTENDEE_EMAIL = "attendee@example.com";
29
+
30
+ let client: Client;
31
+ let dbDir: string;
32
+ let currentUserEmail: string | undefined;
33
+
34
+ beforeEach(async () => {
35
+ dbDir = mkdtempSync(join(tmpdir(), "scheduling-booking-authz-test-"));
36
+ client = createClient({ url: `file:${join(dbDir, `${randomUUID()}.db`)}` });
37
+ await client.execute(`
38
+ CREATE TABLE bookings (
39
+ id TEXT PRIMARY KEY,
40
+ uid TEXT NOT NULL UNIQUE,
41
+ event_type_id TEXT NOT NULL,
42
+ host_email TEXT NOT NULL,
43
+ title TEXT NOT NULL,
44
+ description TEXT,
45
+ start_time TEXT NOT NULL,
46
+ end_time TEXT NOT NULL,
47
+ timezone TEXT NOT NULL DEFAULT 'UTC',
48
+ status TEXT NOT NULL DEFAULT 'confirmed',
49
+ location TEXT,
50
+ custom_responses TEXT,
51
+ cancel_token TEXT,
52
+ reschedule_token TEXT,
53
+ from_reschedule TEXT,
54
+ cancellation_reason TEXT,
55
+ rescheduling_reason TEXT,
56
+ ical_uid TEXT NOT NULL,
57
+ ical_sequence INTEGER NOT NULL DEFAULT 0,
58
+ recurring_event_id TEXT,
59
+ paid INTEGER NOT NULL DEFAULT 0,
60
+ no_show_host INTEGER NOT NULL DEFAULT 0,
61
+ metadata TEXT,
62
+ created_at TEXT NOT NULL,
63
+ updated_at TEXT NOT NULL,
64
+ owner_email TEXT NOT NULL DEFAULT 'local@localhost',
65
+ org_id TEXT,
66
+ visibility TEXT NOT NULL DEFAULT 'private'
67
+ );
68
+ `);
69
+ await client.execute(`
70
+ CREATE TABLE booking_attendees (
71
+ id TEXT PRIMARY KEY,
72
+ booking_id TEXT NOT NULL,
73
+ email TEXT NOT NULL,
74
+ name TEXT NOT NULL,
75
+ timezone TEXT,
76
+ locale TEXT,
77
+ no_show INTEGER NOT NULL DEFAULT 0,
78
+ created_at TEXT NOT NULL
79
+ );
80
+ `);
81
+ await client.execute(`
82
+ CREATE TABLE booking_references (
83
+ id TEXT PRIMARY KEY,
84
+ booking_id TEXT NOT NULL,
85
+ type TEXT NOT NULL,
86
+ external_id TEXT NOT NULL,
87
+ meeting_url TEXT,
88
+ meeting_password TEXT,
89
+ credential_id TEXT,
90
+ created_at TEXT NOT NULL
91
+ );
92
+ `);
93
+ await client.execute(`
94
+ CREATE TABLE booking_notes (
95
+ id TEXT PRIMARY KEY,
96
+ booking_id TEXT NOT NULL,
97
+ author_email TEXT NOT NULL,
98
+ content TEXT NOT NULL,
99
+ created_at TEXT NOT NULL
100
+ );
101
+ `);
102
+
103
+ const db = drizzle(client, { schema });
104
+ currentUserEmail = HOST_EMAIL;
105
+ setSchedulingContext({
106
+ getDb: () => db,
107
+ schema,
108
+ getCurrentUserEmail: () => currentUserEmail,
109
+ });
110
+
111
+ const now = new Date().toISOString();
112
+ await client.execute({
113
+ sql: `INSERT INTO bookings (
114
+ id, uid, event_type_id, host_email, title, start_time, end_time,
115
+ timezone, status, cancel_token, reschedule_token, ical_uid, created_at, updated_at
116
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
117
+ args: [
118
+ "booking-1",
119
+ BOOKING_UID,
120
+ "event-type-1",
121
+ HOST_EMAIL,
122
+ "Test Meeting",
123
+ "2026-08-01T10:00:00.000Z",
124
+ "2026-08-01T10:30:00.000Z",
125
+ "UTC",
126
+ "pending",
127
+ "cancel-token-1",
128
+ "reschedule-token-1",
129
+ "ical-uid-1",
130
+ now,
131
+ now,
132
+ ],
133
+ });
134
+ await client.execute({
135
+ sql: `INSERT INTO booking_attendees (id, booking_id, email, name, created_at) VALUES (?, ?, ?, ?, ?)`,
136
+ args: ["attendee-1", "booking-1", ATTENDEE_EMAIL, "Attendee One", now],
137
+ });
138
+ });
139
+
140
+ afterEach(() => {
141
+ client.close();
142
+ rmSync(dbDir, { recursive: true, force: true });
143
+ });
144
+
145
+ async function bookingStatus(): Promise<string> {
146
+ const { rows } = await client.execute({
147
+ sql: "SELECT status FROM bookings WHERE uid = ?",
148
+ args: [BOOKING_UID],
149
+ });
150
+ return String(rows[0]?.status);
151
+ }
152
+
153
+ async function attendeeEmails(): Promise<string[]> {
154
+ const { rows } = await client.execute({
155
+ sql: "SELECT email FROM booking_attendees WHERE booking_id = 'booking-1'",
156
+ });
157
+ return rows.map((r: any) => String(r.email)).sort();
158
+ }
159
+
160
+ async function attendeeNoShow(email: string): Promise<boolean> {
161
+ const { rows } = await client.execute({
162
+ sql: "SELECT no_show FROM booking_attendees WHERE booking_id = 'booking-1' AND email = ?",
163
+ args: [email],
164
+ });
165
+ return Boolean(rows[0]?.no_show);
166
+ }
167
+
168
+ async function noteCount(): Promise<number> {
169
+ const { rows } = await client.execute("SELECT * FROM booking_notes");
170
+ return rows.length;
171
+ }
172
+
173
+ describe("confirm-booking authorization", () => {
174
+ it("rejects a non-host caller and leaves the booking unchanged", async () => {
175
+ currentUserEmail = OUTSIDER_EMAIL;
176
+ await expect(confirmBooking.run({ uid: BOOKING_UID })).rejects.toThrow(
177
+ /not authorized/i,
178
+ );
179
+ expect(await bookingStatus()).toBe("pending");
180
+ });
181
+
182
+ it("allows the host to confirm the booking", async () => {
183
+ currentUserEmail = HOST_EMAIL;
184
+ const result: any = await confirmBooking.run({ uid: BOOKING_UID });
185
+ expect(result.booking?.status).toBe("confirmed");
186
+ expect(await bookingStatus()).toBe("confirmed");
187
+ });
188
+ });
189
+
190
+ describe("mark-no-show authorization", () => {
191
+ it("rejects a non-host caller and leaves the attendee unchanged", async () => {
192
+ currentUserEmail = OUTSIDER_EMAIL;
193
+ await expect(
194
+ markNoShow.run({ uid: BOOKING_UID, attendeeEmail: ATTENDEE_EMAIL }),
195
+ ).rejects.toThrow(/not authorized/i);
196
+ expect(await attendeeNoShow(ATTENDEE_EMAIL)).toBe(false);
197
+ });
198
+
199
+ it("allows the host to mark an attendee no-show", async () => {
200
+ currentUserEmail = HOST_EMAIL;
201
+ const result: any = await markNoShow.run({
202
+ uid: BOOKING_UID,
203
+ attendeeEmail: ATTENDEE_EMAIL,
204
+ });
205
+ expect(result.ok).toBe(true);
206
+ expect(await attendeeNoShow(ATTENDEE_EMAIL)).toBe(true);
207
+ });
208
+ });
209
+
210
+ describe("add-booking-attendee authorization", () => {
211
+ it("rejects a non-host caller and does not add the attendee", async () => {
212
+ currentUserEmail = OUTSIDER_EMAIL;
213
+ await expect(
214
+ addBookingAttendee.run({
215
+ uid: BOOKING_UID,
216
+ name: "Intruder",
217
+ email: "intruder@example.com",
218
+ }),
219
+ ).rejects.toThrow(/not authorized/i);
220
+ expect(await attendeeEmails()).toEqual([ATTENDEE_EMAIL]);
221
+ });
222
+
223
+ it("allows the host to add an attendee", async () => {
224
+ currentUserEmail = HOST_EMAIL;
225
+ await addBookingAttendee.run({
226
+ uid: BOOKING_UID,
227
+ name: "New Guest",
228
+ email: "guest@example.com",
229
+ });
230
+ expect(await attendeeEmails()).toEqual(
231
+ [ATTENDEE_EMAIL, "guest@example.com"].sort(),
232
+ );
233
+ });
234
+ });
235
+
236
+ describe("remove-booking-attendee authorization", () => {
237
+ it("rejects a non-host caller and does not remove the attendee", async () => {
238
+ currentUserEmail = OUTSIDER_EMAIL;
239
+ await expect(
240
+ removeBookingAttendee.run({ uid: BOOKING_UID, email: ATTENDEE_EMAIL }),
241
+ ).rejects.toThrow(/not authorized/i);
242
+ expect(await attendeeEmails()).toEqual([ATTENDEE_EMAIL]);
243
+ });
244
+
245
+ it("allows the host to remove an attendee", async () => {
246
+ currentUserEmail = HOST_EMAIL;
247
+ await removeBookingAttendee.run({
248
+ uid: BOOKING_UID,
249
+ email: ATTENDEE_EMAIL,
250
+ });
251
+ expect(await attendeeEmails()).toEqual([]);
252
+ });
253
+ });
254
+
255
+ describe("add-booking-note authorization", () => {
256
+ it("rejects a non-host caller and does not create a note", async () => {
257
+ currentUserEmail = OUTSIDER_EMAIL;
258
+ await expect(
259
+ addBookingNote.run({ uid: BOOKING_UID, content: "snooping" }),
260
+ ).rejects.toThrow(/not authorized/i);
261
+ expect(await noteCount()).toBe(0);
262
+ });
263
+
264
+ it("allows the host to add a note", async () => {
265
+ currentUserEmail = HOST_EMAIL;
266
+ const result: any = await addBookingNote.run({
267
+ uid: BOOKING_UID,
268
+ content: "host note",
269
+ });
270
+ expect(result.id).toBeTruthy();
271
+ expect(await noteCount()).toBe(1);
272
+ });
273
+ });
274
+
275
+ describe("send-reschedule-link authorization", () => {
276
+ it("rejects a non-host caller and never returns the reschedule token", async () => {
277
+ currentUserEmail = OUTSIDER_EMAIL;
278
+ await expect(sendRescheduleLink.run({ uid: BOOKING_UID })).rejects.toThrow(
279
+ /not authorized/i,
280
+ );
281
+ });
282
+
283
+ it("returns the reschedule URL with token for the host", async () => {
284
+ currentUserEmail = HOST_EMAIL;
285
+ const result: any = await sendRescheduleLink.run({ uid: BOOKING_UID });
286
+ expect(result.url).toContain("reschedule-token-1");
287
+ });
288
+ });