diff --git a/AGENTS.md b/AGENTS.md
index 4ee48e38b1..0311baa395 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,6 +20,7 @@ reviewing or answering questions about code under its directory:
| `packages/gatekeeper-context/AGENTS.md` | Context Library collections and sharing |
| `packages/gatekeeper-cloudflare/AGENTS.md` | Cloudflare OAuth, billing, Workers Observability |
| `packages/gatekeeper-x/AGENTS.md` | X, whose reads are billed |
+| `packages/gatekeeper-microsoft/AGENTS.md` | Microsoft 365: SharePoint, Outlook, Calendar |
| `packages/gatekeeper-websearch/AGENTS.md` | Web Search |
| `packages/mcp-shared/AGENTS.md` | MCP / MCP Server Portal gatekeepers |
| `packages/observability/AGENTS.md` | server logging, issue reporting, metrics |
diff --git a/README.md b/README.md
index 55ef70df7e..c918762d5f 100644
--- a/README.md
+++ b/README.md
@@ -215,6 +215,7 @@ Each gatekeeper package contains instructions for how to set it up:
* [Home Assistant](packages/gatekeeper-homeassistant/README.md)
* [Slack API](packages/gatekeeper-slack/README.md)
* [Spotify](packages/gatekeeper-spotify/README.md)
+* [Microsoft 365](packages/gatekeeper-microsoft/README.md)
* [X API](packages/gatekeeper-x/README.md)
* [ZoomInfo API](packages/gatekeeper-zoominfo/README.md)
diff --git a/docs/observers.md b/docs/observers.md
index 5d55f26858..3ed258f711 100644
--- a/docs/observers.md
+++ b/docs/observers.md
@@ -852,6 +852,9 @@ its resource types.
| **context** | Context Library singleton | **C** | Track collections whose contents a read revealed (search results, a collection's listing, a document); verify each is in the observer's sharing domain and is public, is everyone-viewable, or grants the observer's Context account a viewer, editor, or owner role. Uses the kit's default observer cap to bound per-read verifier fanout. The top-level collection listing tracks nothing: its titles, descriptions and counts are discovery metadata the agent catalog already advertises unobserved. The denial never names the collection, which the refused collaborator cannot read. |
| **x** | Account | **C** | Admit any observer with a working X connection: public posts, profiles, public Lists and follow graphs need only an X account. Reads private to the account -- bookmarks, likes, mute state, private Lists, and any page holding a protected account's post -- authorize against one synthetic `owner` collection, which admits only an observer connected as the same X user. |
| **x** | Post / List | **C** | As Account, plus a check on every open that the observer's own X account can see the bound post or List (`canViewPost` / `canViewList`). Each check is a read billed to the observer's connection, so a passed check is trusted for an hour; a failed one never is. |
+| **microsoft** | SharePoint (all, site, library, folder, file, list, page) | **C** | Track every object a read revealed as a unit (`sharepoint-observers.ts`), since SharePoint breaks inheritance at any level and one container's ACL never vouches for its contents; verify each by probing it with the observer's own token, twenty to a `$batch`, where 403 and 404 both mean no access. A file's content and its version history are their own units, since someone may see a file they can't open; a draft the owner read pins its version, and the probe passes only if the observer is shown the same one. |
+| **microsoft** | Outlook Mailbox / Folder / Conversation / Sender | **C** | Track the mailbox, folders and messages each read revealed (`outlook-observers.ts`); verify each by a probe `GET` as the observer: the whole mailbox needs access to all of it (someone shared one folder gets 404), a folder or message needs that folder or message. |
+| **microsoft** | Outlook Calendar / Availability | **C** | Track calendar units at the detail level each read revealed (`calendar-observers.ts`): a calendar unit admits an observer only when the owner's `calendarPermissions` give them a role that sees that level (`calendar-acl.ts`, where a group entry can only lower the organization default), which only the owner can read, so a unit of someone else's calendar admits that owner alone; an `availability:
` unit only when `getSchedule` as the observer answers for that address; the all-calendars unit admits nobody but the owner. |
| **x** | Profile | **C** | As Account: a public profile needs only an X account, and the `owner` collection covers a protected user's posts. Accepted over-permissiveness for every X binding: an author who has blocked the observer is still readable through the gadget, since checking each author per observer per read would cost a read each and what leaks is public. |
### 9.3 The "broad binding" lens
diff --git a/packages/gatekeeper-microsoft/AGENTS.md b/packages/gatekeeper-microsoft/AGENTS.md
new file mode 100644
index 0000000000..5efadd5cdb
--- /dev/null
+++ b/packages/gatekeeper-microsoft/AGENTS.md
@@ -0,0 +1,12 @@
+# Microsoft 365 gatekeeper
+
+Microsoft 365 (Entra ID OAuth, Microsoft Graph). It has three resources. **SharePoint**'s binding is one of: all of SharePoint & OneDrive, a site, a library or OneDrive, a folder, a file, a list, or a page; its configurator starts at "all" and drills down. **Outlook**'s is one mailbox (own or shared), optionally narrowed to a folder, a conversation or senders, at `read`/`draft`/`send`. **Calendar**'s is every calendar (for chat), one calendar (own or shared) at `freeBusy`/`limited`/`full` detail (for a gadget shared with that calendar's sharees), or availability only, at `read`/`edit`/`schedule`; `CALENDAR_RESOURCE` (`…/calendar/*`) must stay ahead of `OUTLOOK_RESOURCE` (`…/*`) in `ALL_RESOURCES`, since the Workshop takes the first matching pattern. `plans/microsoft-365-gatekeeper.md`, `plans/microsoft-outlook.md` and `plans/microsoft-calendar.md` are the design records and `storage-schema.md` the storage layout.
+
+- Agents act with the user's delegated permissions; the binding narrows further, and `ScopeGuard` (`sharepoint-scope.ts`) is the chokepoint. Every ID is checked before Graph is called (a folder by walking its ancestry), paths are measured from the binding's top, and a temporary `~N` ID is resolved at every entry point. `graph-api.ts` is the only code that holds a token: it refuses non-Graph hosts, and follows a download redirect once, only to a SharePoint or conversion host, without the `Authorization` header.
+- A connection and its bindings are pinned to `(tenantId, objectId)` from the `id_token`, confirmed against `/me`. Tenant policy (`tenantPolicy` in `microsoft-env.ts`) refuses `common`/`consumers`, and refuses `organizations` without a GUID allowlist. Each resource's effective access is the lowest of the binding's own level (Outlook and Calendar), the deployment ceiling (`MICROSOFT_SHAREPOINT_ACCESS`, `MICROSOFT_OUTLOOK_ACCESS`, `MICROSOFT_CALENDAR_ACCESS`) and the grant's scopes, and a SharePoint file, folder or library binding drops to read when Graph's undocumented `shared.effectiveRoles` says the user can only read it (only ever lowering). `MICROSOFT_SHAREPOINT_SITES=selected` asks for `Sites.Selected` instead of the `.All` permissions, but a connection's behavior follows what it was granted (`grantedSharePoint`), never the setting; a read-only binding's session (`SharePointReader`, `OutlookReader`, `CalendarReader`) has no write methods at all.
+- Observers use tracked collections of *access units* (site/drive/item/list/listItem/page), because SharePoint breaks inheritance at any level. One container's ACL never stands for its contents.
+- Graph behaviours that return wrong-but-plausible data, verified live: `/sites?search=` can return nothing on a working tenant, so sites come from Microsoft Search (`/search/query`) plus followed sites; `/sites/{id}/lists` omits Site Pages, so pages are detected through the pages endpoint; search lags uploads by hours on a new tenant, so nothing may depend on finding a fresh file.
+- Outlook's chokepoint is `OutlookContext.guarded` (`outlook-session.ts`): every message ID is read with its metadata and checked against the scope before anything else is read or changed, and a listing's Graph filter is only an optimization. 400, 403 and 404 all mean "not in this binding". Every request sends `Prefer: IdType="ImmutableId"`, so IDs survive moves and sending. Send is never auto-approvable and is exactly-once through a Message-ID fixed at proposal, found across the whole mailbox before `/send` (`submit` in `outlook-actions.ts` handles the half-second after a 202 when a second `/send` is 403). Inbox rules, permanent deletion, permissions and send-as are deliberately not offered.
+- Exchange gives a mailbox four concurrent requests, so `$batch` entries can 429 individually and `GraphApi.batch` resends them; Sent Items' `from` is a legacy X.500 DN, replaced with the owner's address on read; Outlook web links use EWS IDs, converted in `parseOutlookUrl`. New-message hooks run through one `OutlookHookDriver` per connection and mailbox, which reads by cursor and treats Graph change notifications (when `BASE_URL` is public https) only as a prompt to read sooner.
+- Calendar's chokepoint is `CalendarContext.guarded` (`calendar-session.ts`): an event's parent entry ID (`Binary 0x0E09`) must equal the bound calendar's (`Binary 0x0FFF`, pinned at binding) before anything is shown, and listings are re-confirmed the same way, because a calendar-scoped `calendarView` is not trusted. Redaction to the binding's detail level is the gatekeeper's (`eventInfo` in `calendar-events.ts`), not Graph's: the user's own token sees everything. Observers (`calendar-observers.ts`) admit someone to a calendar unit only when the owner's `calendarPermissions` give them a role that sees that level (`calendar-acl.ts`; a group entry can only lower the org default), to `availability:` only when `getSchedule` as them answers, and to `calendars:` never but the user. Anything that mails someone (`calendar.invitations`, `calendar.responses`) is never auto-approvable and is `claimBeforeApply`; changes are fenced with If-Match on the change key, creations with a `transactionId`.
+- Tests: Node (`vitest.config.ts`, where the configurator frame suites drive the generated frames in Chromium through `__tests__/frame-host.ts`) and workerd against a fake Graph (`vitest.worker.config.ts`), both run by `pnpm --filter @gadgets/microsoft-gatekeeper test:run` after it builds the configurator frames; and a live-tenant contract suite (`vitest.contract.config.ts`), run by `test:contract`, which is skipped unless `.dev.vars` and a tokens file are present.
diff --git a/packages/gatekeeper-microsoft/README.md b/packages/gatekeeper-microsoft/README.md
new file mode 100644
index 0000000000..cd7d50b5ef
--- /dev/null
+++ b/packages/gatekeeper-microsoft/README.md
@@ -0,0 +1,486 @@
+# Gatekeeper Microsoft 365
+
+Connects Microsoft 365 work and school accounts to Gadgets, for [SharePoint and
+OneDrive](#sharepoint-and-onedrive), [Outlook](#outlook) mail and [Outlook Calendar](#calendar), all
+through one connection. Agents act with the user's **delegated** permissions, so they never see more
+than the user can open themselves, and each binding narrows that further. Every change goes through
+the approval workflow, where the user may let a kind of change be auto-approved, except sending mail
+and anything that mails a meeting's attendees, which are never auto-approved. A change shows to the
+agent as though already made while it waits, so the agent keeps working.
+`plans/microsoft-365-gatekeeper.md` is the design record.
+
+## Setting up the app registration
+
+1. In the [Microsoft Entra admin center](https://entra.microsoft.com), open **App registrations →
+ New registration**.
+2. For supported account types, choose **Accounts in this organizational directory only**; a
+ multitenant deployment (`MICROSOFT_TENANT_ID=organizations` with `MICROSOFT_ALLOWED_TENANT_IDS`,
+ below) needs **Accounts in any organizational directory** instead. Then add a **Web** redirect
+ URI, `{PUBLIC_BASE_URL}/gatekeeper/microsoft/oauth`.
+3. Under **API permissions**, add these Microsoft Graph **delegated** permissions:
+ - `openid`, `profile`, `email`, `offline_access`, `User.Read`
+ - `Sites.ReadWrite.All` and `Files.ReadWrite.All`, or `Sites.Read.All` and `Files.Read.All` for
+ a read-only deployment. To reach only sites your SharePoint admin chooses, use `Sites.Selected`
+ and `Files.ReadWrite` instead: see [Every site or only selected sites](#every-site-or-only-selected-sites).
+ - for Outlook: `Mail.ReadWrite`, `Mail.ReadWrite.Shared`, `Mail.Send`, `Mail.Send.Shared`,
+ `MailboxSettings.ReadWrite`, `People.Read` and `User.ReadBasic.All`. A deployment capped below
+ `send` needs neither `Mail.Send` permission, and a `read` one needs only the `.Read` variants.
+ `User.ReadBasic.All` turns a shared mailbox's address into its owner's object ID; without it
+ only the user's own mailbox can be connected.
+ - for Calendar: `Calendars.ReadWrite`, `Calendars.ReadWrite.Shared`, `MailboxSettings.Read`,
+ `People.Read` and `User.ReadBasic.All` (or the `Calendars.Read` variants for a `read`
+ deployment). Without the `.Shared` permission and `User.ReadBasic.All`, only the user's own
+ calendars can be connected.
+4. Select **Grant admin consent for ``**. Many organizations stop users from consenting
+ to these permissions themselves. In that case the connect flow shows an admin-consent page with a
+ link an administrator can open.
+5. Under **Certificates & secrets**, create a client secret. Connections stop refreshing once it
+ expires, so set a reminder to rotate it.
+6. Set the deployment's inputs:
+
+| Name | Kind | What it is |
+|---|---|---|
+| `CLIENT_ID` | secret | The app registration's Application (client) ID. |
+| `CLIENT_SECRET` | secret | The client secret's **Value**, not its ID. |
+| `MICROSOFT_TENANT_ID` | secret | The Directory (tenant) ID, or a verified domain. Only accounts in this tenant can connect. It isn't a credential, but the deploy wizard only collects secrets. |
+| `MICROSOFT_ALLOWED_TENANT_IDS` | var, optional | For a multitenant app. Set `MICROSOFT_TENANT_ID=organizations` and list the tenant IDs (GUIDs) that may connect, comma-separated. `organizations` without this list is refused. `common` and `consumers` are always refused, because personal Microsoft accounts have no SharePoint. |
+| `MICROSOFT_SHAREPOINT_ACCESS` | var, optional | `write` (the default) or `read`; unrecognized values mean `read`. This caps what agents may do whatever the app was granted: the effective access is the lowest of this, the connection's own grant and, for a file, folder or library, the user's own role on it, so something shared with them to view binds read-only. |
+| `MICROSOFT_SHAREPOINT_SITES` | var, optional | `all` (the default) or `selected`; unrecognized values mean `selected`. Which SharePoint permissions a connection asks for: every site the user can open, or only the sites a SharePoint admin grants the app. See [Every site or only selected sites](#every-site-or-only-selected-sites). |
+| `MICROSOFT_OUTLOOK_ACCESS` | var, optional | `send` (the default), `draft` or `read`, capping Outlook bindings the same way. Unrecognized values mean `read`. |
+| `MICROSOFT_CALENDAR_ACCESS` | var, optional | `schedule` (the default), `edit` or `read`, capping Calendar bindings the same way. Unrecognized values mean `read`. |
+
+### Local development
+
+Put `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET` and `MICROSOFT_TENANT_ID` in the root
+`.dev.vars`. `pnpm dev-server` maps the first two into the worker's `CLIENT_ID` and `CLIENT_SECRET`.
+It passes the tenant ID, `MICROSOFT_ALLOWED_TENANT_IDS`, `MICROSOFT_SHAREPOINT_ACCESS`,
+`MICROSOFT_SHAREPOINT_SITES`, `MICROSOFT_OUTLOOK_ACCESS` and `MICROSOFT_CALENDAR_ACCESS` through unchanged. Register
+`http://localhost:8787/gatekeeper/microsoft/oauth` as a Web redirect URI; Entra allows
+`http://localhost` for Web clients.
+
+## SharePoint and OneDrive
+
+Agents browse sites and OneDrive, read files (Word and PowerPoint as Markdown, workbooks as
+cells, and PDFs and legacy Office formats as text where the worker has its optional Workers AI
+binding; without it agents are told to read the bytes instead), query lists and read pages. They can propose new folders and files, uploads,
+in-place edits, renames, moves and deletions, and list item changes. `src/types.d.ts` is the
+agent's API.
+
+A binding covers one of these:
+
+| Choice | Link | What it reaches |
+|---|---|---|
+| All of SharePoint & OneDrive | `https://.sharepoint.com/_layouts/15/sharepoint.aspx` | Every site, library, list and file the user can open, now or later. |
+| A site | `https://.sharepoint.com/sites/` | The site's libraries, lists and pages, but not its subsites. |
+| A library or a OneDrive | `…/sites//Shared%20Documents`, `https://-my.sharepoint.com/personal//Documents` | Every folder and file in it. |
+| A folder | `…/Shared%20Documents/` | Every folder and file inside it. |
+| A file | `…/Shared%20Documents//` | Just that file. |
+| A list | `…/sites//Lists/` | The list's columns and items. |
+| A page | `…/sites//SitePages/.aspx` | Just that page. |
+
+Every ID an agent passes is checked against the binding before Graph is called, including a
+folder's whole ancestry; a proven folder is remembered for a minute, never longer however often it
+is reused, and a listing re-proves its folder for every page. Paths are measured from the top of
+the binding, so a folder binding never reveals the library around it.
+
+The configurator starts at **All of SharePoint & OneDrive**. The user can open a site or their
+OneDrive to choose one part of it, search, or paste any SharePoint or OneDrive link. The OneDrive tab
+also lists what others have shared with the user from their OneDrives, as **Shared with you**. Graph's
+`sharedWithMe` is deprecated, so that list comes from Microsoft Search, which takes minutes to show a
+new share and doesn't include files shared from a site; a link pasted into the picker always works.
+
+### Every site or only selected sites
+
+`MICROSOFT_SHAREPOINT_SITES` chooses between two ways of granting SharePoint:
+
+| | `all` (recommended, the default) | `selected` |
+|---|---|---|
+| Permissions | `Sites.ReadWrite.All`, `Files.ReadWrite.All` | `Sites.Selected`, `Files.ReadWrite` |
+| Reaches | Every site and file the user can already open | Sites a SharePoint admin grants the app, one by one, and the user's own OneDrive |
+| Setup | Admin consent once | Admin consent, then a Graph call per site, and again for each new site |
+| Shared with you | Yes | No |
+| Read-only sites | Show as read-only where Microsoft reports it | Changes are refused when approved |
+
+Either way an agent can never reach more than the user can open, and each binding narrows that
+further. `all` is the better experience and needs no upkeep. Choose `selected` when policy requires
+Microsoft itself to keep the app out of every site that hasn't been approved.
+
+### Only selected sites
+
+To set up `selected`:
+
+1. On the app registration, add the delegated permissions `Sites.Selected`, and `Files.ReadWrite`
+ (or `Files.Read`) if people should reach their own OneDrive, instead of `Sites.ReadWrite.All` and
+ `Files.ReadWrite.All`. Grant admin consent.
+2. Set `MICROSOFT_SHAREPOINT_SITES=selected`, so connections ask for those permissions.
+3. Grant each site to the app. This needs `Sites.FullControl.All`, so a SharePoint admin runs it
+ in [Graph Explorer](https://developer.microsoft.com/graph/graph-explorer) or PowerShell, signed in
+ as themselves, with the app's Application (client) ID:
+
+ ```http
+ GET https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/Engineering?$select=id
+
+ POST https://graph.microsoft.com/v1.0/sites/{id from above}/permissions
+ { "roles": ["write"], "grantedToIdentities": [{ "application": { "id": "", "displayName": "Gadgets" } }] }
+ ```
+
+ Use `"read"` for a site agents should only read. `DELETE …/sites/{id}/permissions/{permission id}`
+ takes the site away again, and revoking consent to `Sites.Selected` takes every site away at once.
+
+Microsoft then lets the app into a site only if the user can open it *and* it was granted to the
+app. The gatekeeper works by what a connection was actually granted, not by the setting, so a
+connection holding both kinds of permission reaches every site. Under `Sites.Selected`:
+
+- **All of SharePoint & OneDrive** means every granted site the user can open, and the Sites tab
+ lists only the sites whose document library Microsoft opens. Microsoft answers for any site's name
+ and ID, granted or not, so the library is the test. A site that hasn't been granted can't be found
+ or bound, and a pasted link to one says to ask the SharePoint admin.
+- The user's own OneDrive is offered only with `Files.Read` or `Files.ReadWrite`. **Shared with you**
+ isn't offered, because nobody else's OneDrive is granted.
+- Microsoft Search doesn't honor `Sites.Selected`, but every hit is read again as the user, which
+ Microsoft refuses outside the granted sites, so hits from other sites are dropped.
+- Write access is decided per site by its grant, which the gatekeeper can't read. A change in a
+ site granted `read` is refused by Microsoft when it's applied.
+
+### SharePoint approvals
+
+Every change is an action that goes through the approval workflow; every SharePoint kind may be
+auto-approved if the user chooses:
+
+| Kind | Actions | Can be auto-approved |
+|---|---|---|
+| `sharepoint.files.create` | Create a folder, upload or copy a file | Yes |
+| `sharepoint.files.edit` | Replace a file's content, edit a Word document or deck in place, set cells in a workbook | Yes |
+| `sharepoint.files.organize` | Rename, move within a library | Yes |
+| `sharepoint.files.delete` | Delete a file or folder | **Never** |
+| `sharepoint.lists.edit` | Add or edit a list item | Yes |
+| `sharepoint.lists.delete` | Delete a list item | **Never** |
+
+A binding offers only the kinds it can submit, so a list binding offers no file actions. Changes are
+fenced: a replace or delete is refused if the file changed after it was proposed (on its `cTag`),
+and an item update if the item changed (on its `eTag`). A file or folder that has since been moved
+out of what the binding covers is refused too, since a move leaves those tags as they were. A second
+change to a file's content is refused while one awaits approval. A create, upload or copy whose name
+turns out to be taken fails rather than adopting what holds it; a copy SharePoint hasn't finished
+after 45 seconds is recorded with an unknown outcome (the copy still lands, so it is cleared by
+rejecting it rather than retried). The approver sees a replacement as a unified diff when the file is
+text and no larger than 64 KB. Applied actions can be reverted for 30 days:
+
+- A replace or in-place edit restores the previous version, while the file is still as the edit left it (restoring replaces the whole file). A cell change puts the range back, while it still holds what was written.
+- A create deletes what it made, unless a created folder has gained contents since; those were never part of the action.
+- A rename or move is undone.
+- An item update restores the values it overwrote.
+- A deletion can't be reverted from here. Restore it from the site's recycle bin.
+
+Things agents can never do, at any access level: share or create sharing links, change permissions,
+change sensitivity labels, check files in or out, create or delete sites, libraries or lists, or
+publish pages.
+
+### Word, Excel and PowerPoint
+
+Files are written in the format their name says, so they open, co-author, print and search like any
+other in the tenant:
+
+- **Word (`.docx`)** from Markdown, in Word's built-in styles (Heading 1–6, List Bullet, Table Grid,
+ and so on): headings, nested and numbered lists, task lists, tables, links, quotes and code.
+- **PowerPoint (`.pptx`)** from Markdown, 16:9: each `#` heading starts a slide, and a blockquote
+ starting "Notes:" becomes speaker notes.
+- **Excel (`.xlsx`)** from rows or CSV, with a bold, frozen, filterable header row. A string starting
+ with `=` is a formula. Formulas that reach outside the workbook (WEBSERVICE, HYPERLINK, RTD, DDE,
+ links to other workbooks) are refused. After an approved write, Excel for the web calculates the
+ workbook, so previews, the PDF view and search show formula results, not empty cells.
+
+A Word document or deck can take its styles, theme, page setup and masters from a template the
+binding covers (`template`). Graph can't convert into Office formats, so the gatekeeper writes the
+Office Open XML itself (`src/content/*-write.ts`).
+
+A file passed as bytes is uploaded as it is, but held to what a written file has by construction
+(`src/content/package-check.ts`): no macros, ActiveX or embedded objects, nothing fetched from
+elsewhere when it is opened, no DDE, and no formula the writer refuses. The check goes by content
+types and relationship types, and reads every XML part with the namespace-aware parser, since a
+byte upload is approved on its name alone. Formats that can run code when opened are refused
+outright: the macro-enabled and legacy binary families (`.docm`, `.xlsm`, `.pptm`, `.xlsb`,
+`.doc`, `.xls`, `.ppt`, `.rtf`, their templates and add-ins) and any OLE compound file.
+
+A whole rewrite of an existing Office file loses what Markdown or rows can't express. The approver is
+told what it will lose, such as "3 images" or "comments". To change part of a file, agents edit in
+place, and everything else is kept:
+
+- `editDocument` makes find/replace, insert and delete-paragraph edits to a `.docx`. By default it
+ records them as tracked changes for the owner to accept in Word, authored ` (agent)`.
+- `editPresentation` makes find/replace, add-slides and delete-slide edits to a `.pptx`.
+- `updateCells` sets a range through Excel's own workbook API, so every other cell, format, chart
+ and sheet is untouched.
+
+The approver sees each edit as before/after lines and each cell change as `B2: old → new`. Reading
+for an edit counts as reading the file, so the [observation rules](#sharing-a-workspace) apply to it.
+
+To see what the writers produce the way people will, `scripts/office-render.ts` uploads files to a
+scratch folder in the contract user's OneDrive, has Microsoft 365 render them to PDF, draws each page
+to PNG with macOS PDFKit, and deletes the folder. From this package's directory:
+
+```sh
+node scripts/office-render.ts out/*.docx out/*.pptx --out /tmp/m365-render
+```
+
+## Outlook
+
+Agents read, search and triage mail, write drafts, and send with approval, in the user's own mailbox
+or a shared one they can open. `plans/microsoft-outlook.md` is the design record and
+`src/outlook-types.d.ts` the agent's API.
+
+A binding is one mailbox, optionally narrowed, at one access level:
+
+| Narrowing | Link | What it reaches |
+|---|---|---|
+| The whole mailbox | `https://outlook.office.com/mail/` | Every folder. |
+| A folder | `…/mail/inbox`, `…/mail/folder/`, `?subfolders=0` | That folder, with its subfolders unless excluded. Resolved once, at binding, to an immutable ID, so renaming or moving it changes nothing. |
+| A conversation | `…/mail/inbox/id/`, `?conversation=` | Every message in the thread, wherever it is filed. |
+| People | `?senders=ada@contoso.com,@fabrikam.com` | Mail from those addresses or domains, by its From and Sender headers, which the sender writes: a way to narrow what the agent reads, not a defence against spoofed mail. |
+
+A shared mailbox's links put its address first, `…/mail/team@contoso.com/inbox`. Narrowings
+combine, and `&access=read|draft|send` sets the level. Links copied from Outlook on the
+web, including "Open in Outlook" links (`/owa/?ItemID=…`), are read as they are.
+
+Every message ID an agent passes is read first and checked against the binding, so a narrowed
+binding can't reach a message outside it by guessing or pasting an ID, and an approved move or
+update checks its messages and destination again before writing, since a message keeps its ID
+wherever the user moves it meanwhile. Drafts a binding wrote stay in it whatever its narrowing, so
+a sender-narrowed binding can reply, and only those can be deleted through it. Junk Email, Deleted Items,
+Outbox and the hidden folders are left out of narrowed bindings unless named. Mailbox settings
+(automatic replies) need a whole-mailbox binding.
+
+The levels:
+
+| Level | Adds | Kind | Can be auto-approved |
+|---|---|---|---|
+| `read` | Reading, search, attachments (Office files as text; PDFs and workbooks too with the Workers AI binding), categories, people | — | — |
+| `draft` | Move, archive, delete (to Deleted Items), mark read, flag, categorize, create folders | `outlook.organize` | Yes |
+| `draft` | Create drafts, including replies and forwards that keep the quoted thread, and edit and delete the drafts it wrote (never the user's own, which an auto-approved edit could change under them) | `outlook.drafts` | Yes |
+| `send` | Send a draft, a new message, a reply or a forward | `outlook.send` | **Never** |
+| `send`, whole mailbox | Automatic replies, which answer whoever writes | `outlook.settings` | **Never** |
+
+A `read` binding's session is an `OutlookReader`, with no write methods at all, and a `draft`
+binding's an `OutlookDrafter`, without the send methods. Everything else is
+an action, shown to the agent as though already applied while it waits, and reverted for 30 days
+where Exchange allows: moves go back, flags, categories and read state are restored, folders and
+drafts are deleted, a draft edit is undone, automatic replies return to what they were. An undo is
+proved against the binding as applying was: a message or folder that has since left it is left as
+it is. A send can't be unsent.
+
+The approver of a send sees every recipient, marked when they're outside the organization, when the
+user has never written to them, or when they aren't in the conversation being replied to; the body
+verbatim; each attachment with its size and, for one the agent added, its SHA-256; and for a forward,
+the forwarded message's text and attachments. A draft edit is fenced on the draft's `changeKey`, so
+it's refused if the user changed the draft meanwhile, and a send of a draft is refused when an edit
+of ours changed it after the send was proposed.
+
+A send is exactly-once. It carries a Message-ID chosen when it was proposed, and applying it first
+looks for that ID across the whole mailbox (Outbox included), so a retry after a crash finds the
+sent message rather than sending again.
+
+Mail is written by its sender, so `readMessage` turns HTML into Markdown and removes what a person
+couldn't see or an agent shouldn't follow (scripts, hidden text, tracking images; the message's own
+stylesheet counts, for hiding rules and for the colours and sizes that make text unreadable), lists links
+separately with their real targets, says whether the sender is the user, a colleague or someone
+outside the organization, and says when a message is encrypted rather than showing it empty.
+
+Agents can never, at any level: create inbox rules (a forwarding rule is a standing channel out),
+delete permanently, change mailbox or folder permissions or delegates, or send as someone else.
+
+**New-message hooks.** `subscribeNewMessages(hook)` delivers each new message the binding admits,
+with a session for the same binding, through an `OutlookHookDriver` per connection and mailbox. The
+driver reads what arrived since its cursor, so a lost, late or forged notification costs at most a
+read. Where the deployment has a public `https` `BASE_URL`, it also keeps a Graph change-notification
+subscription (`{BASE_URL}/outlook/notifications/`, checked by a secret `clientState`, renewed
+halfway through its three days) and reads within seconds of a notification; it polls every two
+minutes without one and hourly with one. A hook delivers nothing until the user enables it, and
+disconnecting the account stops its hooks.
+
+## Calendar
+
+Agents read calendars, check when people are free, and propose events, meetings and responses, in
+the user's own calendars or ones shared with them. `plans/microsoft-calendar.md` is the design
+record and `src/calendar-types.d.ts` the agent's API.
+
+A binding is one of three shapes, each made for a use:
+
+| Shape | Link | What it reaches | Made for |
+|---|---|---|---|
+| Every calendar | `https://outlook.office.com/calendar/` (or any `…/calendar/view/…` link) | All the user's calendars (private events in full on the user's own calendars, as busy time on calendars shared with them), and availability of anyone in the organization. | Chat, as the user's own assistant. |
+| One calendar | `…/calendar/default`, `…/calendar/id/`, and `…/calendar//…` for one shared with the user | That calendar alone, at a level of detail: `?details=freeBusy\|limited\|full`, and `&private=show` to show private events. | A gadget showing a team calendar, shared with whoever that calendar is shared with. |
+| Availability | `…/calendar/availability` | Busy times and working hours of people in the organization, never what the events are. | Scheduling, without reading anyone's calendar. |
+
+`&access=read|edit|schedule` sets the level. A calendar binding is resolved once, at binding, to the
+calendar's immutable ID and its entry ID, so renaming the calendar changes nothing, and every event
+an agent names is read and checked to be in that calendar before anything is shown. Graph's own
+listings are rechecked the same way, and a stray event from another calendar is dropped and logged.
+
+What a calendar binding shows follows Exchange's own sharing levels, enforced by the gatekeeper, not
+just by what Graph returns: `freeBusy` is times and show-as only, `limited` adds subject and
+location, `full` adds attendees, the description and the rest. A private event shows as a busy block
+unless the binding shows private events. An availability binding answers through `getSchedule`,
+which never returns subjects, and finds free time across people's working hours, each in their own
+time zone.
+
+The levels:
+
+| Level | Adds | Kind | Can be auto-approved |
+|---|---|---|---|
+| `read` | Events, availability, free times, people | — | — |
+| `edit` | Create, change and delete events only the user sees (no attendees), and their own copy's categories, show-as and reminder on others' meetings | `calendar.events` | Yes |
+| `schedule` | Meetings: invitations, updates and cancellations | `calendar.invitations` | **Never** |
+| `schedule` | Accept, tentatively accept or decline, optionally proposing another time | `calendar.responses` | **Never** |
+| `edit` | Create, change and delete events in a calendar someone else owns | `calendar.shared-events` | **Never** |
+
+Only a `full` binding can change events, since nothing a binding can't fully see can be changed
+through it. A read binding's session is a `CalendarReader`, with no write methods at all. A change
+is fenced on the event's `changeKey`, so an edit made in Outlook after the proposal stops it, and a
+change or deletion proposed against an event not yet created is fenced on the version its creation
+left, so an event that gained attendees in Outlook meanwhile isn't quietly changed as one nobody
+else is on; a creation carries a `transactionId`, so a retry can't make two. Events only the user
+sees revert for 30 days: a creation is deleted, a change restored, a deletion recreated as a new
+event; one that has since left the calendar the action was approved for is left as it is. Anything
+that sent mail can't be called back, and says so. The approver of a meeting sees every attendee,
+marked when outside the organization, and while a change to who they are is pending, nothing more
+is proposed to the meeting, since nobody could say whom it would reach.
+
+Agents can never change who a calendar is shared with, delete someone else's meeting (they decline
+it), or change a meeting they don't organize.
+
+## Sharing a workspace
+
+A collaborator can observe a workspace that reads SharePoint only if all of these hold:
+
+- they have their own working Microsoft 365 connection
+- that connection is in the same tenant
+- their own account can open everything the workspace has already read
+
+For Outlook, the same holds for mail: a collaborator observes mail reads only if they can open
+that mailbox themselves, so a workspace reading a shared mailbox can be shared with its members,
+while one reading the user's own mail is kept from everyone else.
+
+For Calendar, a collaborator observes a calendar binding's reads only if that calendar's sharing list
+gives them a role that sees at least as much as the binding shows (`freeBusyRead` for `freeBusy`,
+`limitedRead` for `limited`, `read` for `full`, delegate access to private events for `private=show`).
+Only a calendar's owner can read its sharing list, so this decides the user's own calendars; a binding
+on someone else's calendar is observed by that calendar's owner and nobody else.
+So a gadget showing the team calendar at free/busy can be shared with the whole organization if the
+calendar is, and one showing full details only with the people it is shared with at `read`. An
+availability read admits whoever can see those people's free/busy themselves, and an every-calendar
+binding that has read events is the user's alone.
+
+SharePoint can break permission inheritance on any folder, file or list item. So the gatekeeper
+tracks each thing a read reveals (up to 2,000 per binding) rather than one container. Each later
+read is checked against every observer, and a read that reveals something an observer can't open is
+blocked for as long as they remain one. See `docs/observers.md`.
+
+## Troubleshooting
+
+### "An administrator needs to approve this app"
+
+The organization allows only administrators to consent to the SharePoint, mail or calendar
+permissions. An administrator can grant consent from the link on the page, or from the app
+registration's **API permissions** page.
+
+### "That account can't connect here"
+
+The user signed in with an account from another tenant, or with a personal Microsoft account. Sign
+in with a work or school account from the deployment's own tenant.
+
+### "Your organization blocked this sign-in"
+
+A Conditional Access policy refused the sign-in, for example because it requires a managed device.
+The organization's administrators decide this.
+
+### "That's a different Microsoft 365 account"
+
+The reconnect signed in as a different user from the one the connection was made for. A connection
+stays pinned to its tenant and user. Reconnect as that user, or connect the other account
+separately.
+
+### "The Microsoft 365 connection has expired or was revoked."
+
+Entra refused the refresh token. Typical causes:
+
+- the user's sessions were revoked
+- their password was reset
+- the client secret expired or was rotated
+- a Conditional Access policy now requires an interactive sign-in
+
+Reconnect from the Workshop's Connections page.
+
+### Search finds nothing in a new site
+
+SharePoint indexes new content in the background. On a new tenant that can take hours or more.
+Browsing and reading don't depend on the index; search and the configurator's site list do. Until
+the index catches up, the site list falls back to the user's home and followed sites.
+
+## Tests
+
+```sh
+pnpm --filter @gadgets/microsoft-gatekeeper test:run # Node, then workerd against a fake Graph
+```
+
+These are the tests CI runs, and all a change normally needs: they cover the logic against fakes of
+Entra and Graph. The configurator frame tests (`__tests__/*-configurator-frame.test.ts`) drive the
+generated frames in Chromium through `__tests__/frame-host.ts`, so they need Chromium installed once
+with `pnpm --filter @gadgets/workshop-frontend exec playwright install chromium`.
+
+### Live suite (optional)
+
+A second suite runs the gatekeeper against a real Microsoft 365 tenant, as real users, for the
+behaviour a fake can only assume. It is never part of `test` or CI, and skips itself unless set up as
+below. Run it when you change how the gatekeeper talks to Microsoft. It applies real changes in the
+test tenant and cleans up after itself, so use a tenant kept for the purpose, never a production one.
+
+**1. A test tenant with three licensed users** (Microsoft 365 with Exchange Online and SharePoint),
+known to the suite as `phillip`, `sarah` and `bob`:
+
+| Fixture | Set up as | What it tests |
+|---|---|---|
+| **Engineering** team site | Private. Phillip owns it, Sarah is a member, Bob isn't. `Documents/Specs/` holds `apollo-brief.docx`, `apollo-budget.xlsx` and `Deep/notes.txt`; an `Issue Tracker` list has a Status choice column and a Priority number column | reads, list queries, observers refusing a non-member |
+| **Marketing** communication site | Sarah and Bob are visitors. `Documents/Launch/` holds `roster.csv`; `Documents/Confidential/` has inheritance broken and Visitors removed; a `Launch Plan` page | observers, hidden folders, pages |
+| **Phillip's OneDrive** | `GadgetsSpike/Apollo` shared read-only with Sarah; `GadgetsSpike/Private/secret.txt` shared with nobody; a top-level `Shared with Sarah` folder holding one Word document, the folder shared read-only with Sarah (Share, "Can view") | OneDrive sharing, shortcuts, the configurator's Shared with you |
+| **Phillip's calendars** | A calendar named `Gadgets Spike Team`, shared with Sarah as "Can view all details" and with Bob as "Can view when I'm busy" | calendar bindings and observers by detail level |
+| **Bob's calendar** | His main calendar shared with nobody, not even free/busy (Outlook: Calendar permissions, "My Organization": none) | unknown availability never reads as free |
+| **Phillip's mailbox** | Sarah is a Reviewer of his Inbox and of no other folder (Outlook: Inbox, Sharing and permissions) | observers of a folder-only delegate |
+
+**2. An app registration** in that tenant, made as the deploy wizard describes (`deploy-inputs.json`),
+with admin consent granted, plus `http://localhost:8765/callback` as a second Web redirect URI for
+signing the test users in. Its credentials go in the repository's root `.dev.vars`, the same ones
+`pnpm dev-server` uses:
+
+```
+MICROSOFT_CLIENT_ID=…
+MICROSOFT_CLIENT_SECRET=…
+MICROSOFT_TENANT_ID=…
+```
+
+**3. Sign each user in once,** in a browser, so MFA and Conditional Access apply as for anyone:
+
+```sh
+pnpm --filter @gadgets/microsoft-gatekeeper contract:sign-in phillip # then sarah, then bob
+```
+
+This saves each user's refresh token to `~/.config/gadgets-microsoft-contract/tokens.json` (or the
+file `MICROSOFT_CONTRACT_TOKENS` names), readable only by you. The suite rotates the tokens in place,
+so a user needs signing in again only if their token is revoked or goes unused for 90 days. A team
+can share one tenant by sharing `.dev.vars` and the tokens file through a password manager.
+
+**4. Run it:**
+
+```sh
+pnpm --filter @gadgets/microsoft-gatekeeper test:contract # 67 tests, a few minutes
+MICROSOFT_LIVE=1 pnpm --filter @gadgets/microsoft-gatekeeper exec vitest run __tests__/outlook-configurator.live.test.ts __tests__/calendar-configurator.live.test.ts
+```
+
+What it does to the tenant: file writes happen in a scratch folder it creates and deletes;
+`office.test.ts` also has Microsoft 365 render each Office file it writes to PDF, which proves Office
+for the web opens it. `outlook.test.ts` has the three users mail one another and deletes what it
+sent. `calendar.test.ts` makes its events five weeks out and deletes them. The hooks test makes a
+real Graph subscription when `MICROSOFT_CONTRACT_BASE_URL` is a public `https` URL (a `cloudflared`
+tunnel will do), and polls otherwise.
+
+`node scripts/office-render.ts ...` uses the same credentials to render any Office file the
+way Microsoft 365 does, for looking at what the writers produce.
diff --git a/packages/gatekeeper-microsoft/__tests__/calendar-actions.test.ts b/packages/gatekeeper-microsoft/__tests__/calendar-actions.test.ts
new file mode 100644
index 0000000000..127a604e98
--- /dev/null
+++ b/packages/gatekeeper-microsoft/__tests__/calendar-actions.test.ts
@@ -0,0 +1,389 @@
+// Applying and reverting Calendar actions against a fake Graph at `fetch`: what each kind sends,
+// how it is fenced against a change made since it was proposed, and what its revert does.
+
+import { describe, expect, it } from "vitest";
+import { ActionApplyError } from "@gadgets/gatekeeper-kit/actions";
+import { ProvisionalIds, type SimulationKv } from "@gadgets/gatekeeper-kit/simulation";
+import {
+ definitions, requiredAccess, revert, type CalendarAction, type CalendarActionHost, type CalendarActions, type CalendarRef, type EventTarget, type EventVersion,
+} from "../src/calendar-actions";
+import { GraphApi } from "../src/graph-api";
+
+const ZONE = "America/Los_Angeles";
+const CALENDAR: CalendarRef = { owner: { kind: "me" }, id: "AAMkCal", name: "Calendar" };
+const SHARED: CalendarRef = { owner: { kind: "shared", id: "bbbbbbbb-0000-0000-0000-000000000002", address: "sarah@contoso.com" }, id: "AAMkSarahCal", name: "Team", ownerAddress: "sarah@contoso.com" };
+const TARGET: EventTarget = { id: "AAMkEv", subject: "Standup", start: "2026-10-19T16:00:00.000Z", end: "2026-10-19T16:30:00.000Z", allDay: false, changeKey: "ck1" };
+
+type Sent = { method: string; path: string; body?: Record; ifMatch: string | null };
+
+/** A Graph that answers each request with the next scripted reply, recording what was sent. */
+function harness(replies: { status: number; body?: unknown }[] = []) {
+ const sent: Sent[] = [];
+ const fetch = async (input: string | URL | Request, init?: RequestInit) => {
+ const url = new URL(String(input));
+ sent.push({
+ method: init?.method ?? "GET", path: `${url.pathname.replace(/^\/v1\.0/, "")}${url.search}`,
+ body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, ifMatch: new Headers(init?.headers).get("if-match"),
+ });
+ const reply = replies.shift() ?? { status: 204 };
+ return reply.status === 204 ? new Response(null, { status: 204 }) : Response.json(reply.body ?? {}, { status: reply.status });
+ };
+ const api = new GraphApi("token", { fetch });
+ const store = new Map();
+ const kv = {
+ get: (key: string) => store.get(key), put: (key: string, value: unknown) => { store.set(key, value); },
+ delete: (key: string) => store.delete(key), list: () => store.entries(),
+ } as unknown as SimulationKv;
+ let invalidated = 0;
+ const versions = new Map();
+ const host: CalendarActionHost = {
+ refs: new ProvisionalIds(kv, { namespace: "refs:", isProvisional: id => id.startsWith("~") }),
+ graph: op => op(api),
+ invalidate: async () => { invalidated += 1; },
+ noteEventVersion: (id, changeKey) => { versions.set(id, { changeKey, at: Date.now() }); },
+ eventVersion: id => versions.get(id),
+ };
+ return { host, sent, invalidated: () => invalidated, versions };
+}
+
+/** What an undo reads first: where the event is and whether it is the user's meeting, then the calendar the action was approved for. */
+const placed = (body: Record = {}, parent = "entry-cal") => ({ status: 200, body: { isOrganizer: true, attendees: [], singleValueExtendedProperties: [{ id: "Binary 0x0E09", value: parent }], ...body } });
+const boundCalendar = { status: 200, body: { id: "AAMkCal", name: "Calendar", singleValueExtendedProperties: [{ id: "Binary 0x0FFF", value: "entry-cal" }] } };
+
+const conflict = { status: 412, body: { error: { code: "ErrorIrresolvableConflict", message: "The send or update operation could not be performed because the change key passed in the request does not match the current change key for the item." } } };
+
+const createPayload: CalendarActions["createEvent"] = {
+ calendar: CALENDAR, ref: "~1", transactionId: "7d1c0b1e-6c5e-4f0e-9d7c-000000000001", zone: ZONE, submittedAt: 1,
+ event: { subject: "Focus", start: "2026-10-19T16:00:00.000Z", end: "2026-10-19T17:00:00.000Z", allDay: false, reminder: null, categories: ["Deep work"] },
+};
+
+describe("creating", () => {
+ it("posts into the chosen calendar in the user's zone, under its transaction ID, and binds the temporary ID", async () => {
+ const { host, sent, invalidated } = harness([{ status: 201, body: { id: "AAMkNew" } }]);
+ const { action } = await definitions.createEvent.apply(createPayload, host);
+ expect(sent[0]).toMatchObject({
+ method: "POST", path: "/me/calendars/AAMkCal/events",
+ body: {
+ subject: "Focus", isAllDay: false, isReminderOn: false, categories: ["Deep work"], transactionId: createPayload.transactionId,
+ start: { dateTime: "2026-10-19T09:00:00", timeZone: ZONE }, end: { dateTime: "2026-10-19T10:00:00", timeZone: ZONE },
+ },
+ });
+ expect(action.createdId).toBe("AAMkNew");
+ expect(host.refs.resolve("~1")).toBe("AAMkNew");
+ expect(invalidated()).toBe(1);
+ });
+
+ it("writes an all-day event as dates, and a shared calendar through its owner", async () => {
+ const { host, sent } = harness([{ status: 201, body: { id: "AAMkNew" } }]);
+ await definitions.createEvent.apply({ ...createPayload, calendar: SHARED, event: { subject: "Offsite", start: "2026-10-19", end: "2026-10-21", allDay: true } }, host);
+ expect(sent[0]).toMatchObject({
+ path: `/users/${SHARED.owner.kind === "shared" ? SHARED.owner.id : ""}/calendars/AAMkSarahCal/events`,
+ body: { isAllDay: true, start: { dateTime: "2026-10-19T00:00:00", timeZone: ZONE }, end: { dateTime: "2026-10-21T00:00:00", timeZone: ZONE } },
+ });
+ });
+
+ it("sends a meeting's attendees and Teams link", async () => {
+ const { host, sent } = harness([{ status: 201, body: { id: "AAMkNew" } }]);
+ await definitions.createMeeting.apply({
+ ...createPayload,
+ event: { ...createPayload.event, onlineMeeting: true, attendees: [{ address: "sarah@contoso.com", name: "Sarah", notes: [] }, { address: "ada@fabrikam.com", optional: true, notes: ["outside your organization"] }] },
+ }, host);
+ expect(sent[0]!.body).toMatchObject({
+ isOnlineMeeting: true, onlineMeetingProvider: "teamsForBusiness",
+ attendees: [{ emailAddress: { address: "sarah@contoso.com", name: "Sarah" }, type: "required" }, { emailAddress: { address: "ada@fabrikam.com" }, type: "optional" }],
+ });
+ });
+
+ it("fails for good on a refusal, and leaves an answer without an ID to be retried", async () => {
+ const refused = harness([{ status: 403, body: { error: { code: "ErrorAccessDenied", message: "Access is denied." } } }]);
+ await expect(definitions.createEvent.apply(createPayload, refused.host)).rejects.toBeInstanceOf(ActionApplyError);
+ const odd = harness([{ status: 201, body: {} }]);
+ const error = await definitions.createEvent.apply(createPayload, odd.host).catch(caught => caught);
+ expect(error).not.toBeInstanceOf(ActionApplyError);
+ });
+});
+
+describe("changing and deleting", () => {
+ const updatePayload: CalendarActions["updateEvent"] = {
+ calendar: CALENDAR, target: TARGET, zone: ZONE, submittedAt: 1,
+ changes: { subject: "Standup (moved)", start: "2026-10-19T17:00:00.000Z", end: "2026-10-19T17:30:00.000Z", allDay: false },
+ before: { subject: "Standup", start: TARGET.start, end: TARGET.end, allDay: false },
+ };
+
+ it("patches only what changed, fenced on the change key it was proposed with", async () => {
+ const { host, sent } = harness([{ status: 200, body: {} }]);
+ await definitions.updateEvent.apply(updatePayload, host);
+ expect(sent[0]).toEqual({
+ method: "PATCH", path: "/me/events/AAMkEv", ifMatch: 'W/"ck1"',
+ body: { subject: "Standup (moved)", isAllDay: false, start: { dateTime: "2026-10-19T10:00:00", timeZone: ZONE }, end: { dateTime: "2026-10-19T10:30:00", timeZone: ZONE } },
+ });
+ });
+
+ it("changes nothing when the event changed since, and says so", async () => {
+ const { host } = harness([conflict]);
+ await expect(definitions.updateEvent.apply(updatePayload, host)).rejects.toThrow(new ActionApplyError("The event changed after this was proposed, so it was not applied. Ask the agent to read it again."));
+ });
+
+ const provisional: CalendarActions["deleteEvent"] = { calendar: CALENDAR, target: { ...TARGET, id: "~1", changeKey: "" }, zone: ZONE, submittedAt: 2 };
+
+ it("changes an event created earlier in the same batch by its real ID, fenced on the version its creation left", async () => {
+ const { host, sent } = harness([{ status: 201, body: { id: "AAMkNew", changeKey: "ckA" } }, placed(), { status: 200, body: { changeKey: "ckB" } }, placed()]);
+ const { action: made } = await definitions.createEvent.apply(createPayload, host);
+ expect(made.createdChangeKey).toBe("ckA");
+ await definitions.updateEvent.apply({ ...updatePayload, target: provisional.target }, host);
+ expect(sent[2]).toMatchObject({ method: "PATCH", path: "/me/events/AAMkNew", ifMatch: 'W/"ckA"' });
+ // The next action on it is fenced on what that change left.
+ await definitions.deleteEvent.apply(provisional, host);
+ expect(sent[4]).toMatchObject({ method: "DELETE", path: "/me/events/AAMkNew", ifMatch: 'W/"ckB"' });
+ await expect(definitions.updateEvent.apply({ ...updatePayload, target: { ...TARGET, id: "~2" } }, harness().host)).rejects.toThrow();
+ });
+
+ it("won't change or delete a real event once an invitation proposed before it was approved, which made it a meeting", async () => {
+ const invited = placed({ attendees: [{ emailAddress: { address: "sarah@contoso.com" } }] });
+ const { host, sent } = harness([invited, invited]);
+ host.noteEventVersion(TARGET.id, "ckAfterInvite");
+ await expect(definitions.updateEvent.apply({ ...updatePayload, submittedAt: 1 }, host)).rejects.toThrow(/become a meeting/);
+ await expect(definitions.deleteEvent.apply({ ...provisional, target: TARGET, submittedAt: 1 }, host)).rejects.toThrow(/become a meeting/);
+ expect(sent.filter(request => request.method !== "GET")).toHaveLength(0);
+ });
+
+ it("won't change or delete an event it created once a later-approved invitation made it a meeting, whatever version that left", async () => {
+ const invited = placed({ attendees: [{ emailAddress: { address: "sarah@contoso.com" } }] });
+ const { host, sent } = harness([{ status: 201, body: { id: "AAMkNew", changeKey: "ckA" } }, invited, invited]);
+ await definitions.createEvent.apply(createPayload, host);
+ host.noteEventVersion("AAMkNew", "ckAfterInvite");
+ await expect(definitions.updateEvent.apply({ ...updatePayload, target: provisional.target }, host)).rejects.toThrow(/become a meeting/);
+ await expect(definitions.deleteEvent.apply(provisional, host)).rejects.toThrow(/become a meeting/);
+ expect(sent.filter(request => request.method !== "GET")).toHaveLength(1);
+ });
+
+ it("won't delete an event it created once Outlook changed it, as it may have become a meeting", async () => {
+ const { host } = harness([{ status: 201, body: { id: "AAMkNew", changeKey: "ckA" } }, placed(), conflict]);
+ await definitions.createEvent.apply(createPayload, host);
+ await expect(definitions.deleteEvent.apply(provisional, host))
+ .rejects.toThrow(new ActionApplyError("The event changed after this was proposed, so it was not applied. Ask the agent to read it again."));
+ // Nor without knowing what version its creation left.
+ const unknown = harness();
+ unknown.host.refs.bind("~1", "AAMkNew");
+ await expect(definitions.deleteEvent.apply(provisional, unknown.host)).rejects.toThrow(/version since it was created isn't known/);
+ expect(unknown.sent).toEqual([]);
+ });
+
+ const undoable = { ...updatePayload, appliedChangeKey: "ckB", appliedAt: 2 };
+
+ it("reverts a change by patching back just the fields it changed, fenced on the version the change left", async () => {
+ const { host, sent } = harness([placed(), boundCalendar, { status: 200, body: {} }]);
+ await revert({ kind: "updateEvent", payload: { ...undoable, before: { ...updatePayload.before, location: "never touched" } } }, host);
+ expect(sent.slice(0, 2).map(request => request.path.replace(/\?.*/, ""))).toEqual(["/me/events/AAMkEv", "/me/calendars/AAMkCal"]);
+ expect(sent[2]).toMatchObject({ method: "PATCH", path: "/me/events/AAMkEv", ifMatch: 'W/"ckB"', body: { subject: "Standup", start: { dateTime: "2026-10-19T09:00:00" } } });
+ expect(sent[2]!.body).not.toHaveProperty("location");
+ const edited = harness([placed(), boundCalendar, conflict]);
+ expect(await revert({ kind: "updateEvent", payload: undoable }, edited.host)).toMatchObject({ message: expect.stringContaining("changed since"), canRetry: false });
+ const unknown = harness([]);
+ expect(await revert({ kind: "updateEvent", payload: updatePayload }, unknown.host)).toMatchObject({ message: expect.stringContaining("which version"), canRetry: false });
+ expect(unknown.sent).toEqual([]);
+ });
+
+ it("records the version a change leaves, for its undo", async () => {
+ const { host } = harness([{ status: 200, body: { changeKey: "ckB" } }]);
+ const { action } = await definitions.updateEvent.apply(updatePayload, host);
+ expect(action.appliedChangeKey).toBe("ckB");
+ });
+
+ it("won't revert a change once the event has become a meeting the user organizes, as that would mail everyone on it", async () => {
+ const meeting = placed({ attendees: [{ emailAddress: { address: "sarah@contoso.com" } }] });
+ const changed = harness([meeting, boundCalendar]);
+ expect(await revert({ kind: "updateEvent", payload: undoable }, changed.host)).toMatchObject({ message: expect.stringContaining("become a meeting"), canRetry: false });
+ expect(changed.sent.map(request => request.method)).toEqual(["GET", "GET"]);
+ const created = harness([meeting, boundCalendar]);
+ expect(await revert({ kind: "createEvent", payload: { ...createPayload, createdId: "AAMkNew", createdChangeKey: "ckA" } }, created.host)).toMatchObject({ canRetry: false });
+ expect(created.sent.map(request => request.method)).toEqual(["GET", "GET"]);
+ });
+
+ it("leaves alone an event that has left the calendar the action was approved for, or whose calendar is gone", async () => {
+ const elsewhere = harness([placed({}, "entry-other"), boundCalendar]);
+ expect(await revert({ kind: "updateEvent", payload: undoable }, elsewhere.host)).toEqual({ message: expect.stringContaining("no longer in the calendar"), canRetry: false });
+ expect(elsewhere.sent.map(request => request.method)).toEqual(["GET", "GET"]);
+ const noCalendar = harness([placed(), { status: 404, body: { error: { code: "ErrorItemNotFound", message: "x" } } }]);
+ expect(await revert({ kind: "createEvent", payload: { ...createPayload, createdId: "AAMkNew", createdChangeKey: "ckA" } }, noCalendar.host)).toEqual({ message: expect.stringContaining("no longer in the calendar"), canRetry: false });
+ expect(noCalendar.sent.map(request => request.method)).toEqual(["GET", "GET"]);
+ });
+
+ it("still reverts the user's own fields on someone else's invitation, which tells nobody", async () => {
+ const { host, sent } = harness([placed({ isOrganizer: false, attendees: [{ emailAddress: { address: "me@contoso.com" } }] }), boundCalendar, { status: 200, body: {} }]);
+ await revert({ kind: "updateEvent", payload: { ...undoable, changes: { categories: ["Vendors"] }, before: { categories: [] } } }, host);
+ expect(sent[2]).toMatchObject({ method: "PATCH", body: { categories: [] } });
+ });
+
+ it("deletes fenced on the change key, and takes an event already gone as deleted", async () => {
+ const payload: CalendarActions["deleteEvent"] = { calendar: CALENDAR, target: TARGET, zone: ZONE, submittedAt: 5, snapshot: { subject: "Standup", start: TARGET.start, end: TARGET.end, allDay: false, location: "Room 1" } };
+ const { host, sent } = harness([{ status: 404, body: { error: { code: "ErrorItemNotFound", message: "x" } } }]);
+ await definitions.deleteEvent.apply(payload, host);
+ expect(sent[0]).toMatchObject({ method: "DELETE", path: "/me/events/AAMkEv", ifMatch: 'W/"ck1"' });
+ await expect(definitions.deleteEvent.apply(payload, harness([conflict]).host)).rejects.toBeInstanceOf(ActionApplyError);
+ });
+
+ it("puts a deleted event back as a new event, under a transaction ID a retried revert reuses", async () => {
+ const payload: CalendarActions["deleteEvent"] = { calendar: CALENDAR, target: TARGET, zone: ZONE, submittedAt: 5, snapshot: { subject: "Standup", start: TARGET.start, end: TARGET.end, allDay: false, location: "Room 1" } };
+ const first = harness([{ status: 201, body: { id: "AAMkBack" } }]);
+ const outcome = await revert({ kind: "deleteEvent", payload }, first.host);
+ expect(outcome).toEqual({ message: expect.stringContaining("as a new event") });
+ const second = harness([{ status: 201, body: { id: "AAMkBack" } }]);
+ await revert({ kind: "deleteEvent", payload }, second.host);
+ expect(first.sent[0]!.body).toMatchObject({ subject: "Standup", location: { displayName: "Room 1" }, transactionId: "revert-AAMkEv-5" });
+ expect(second.sent[0]!.body!.transactionId).toBe(first.sent[0]!.body!.transactionId);
+ expect(await revert({ kind: "deleteEvent", payload: { ...payload, snapshot: undefined } }, harness().host)).toMatchObject({ canRetry: false });
+ });
+
+ it("reverts a creation by deleting what it created, fenced on the version it left, even if it is already gone", async () => {
+ const gone = { status: 404, body: { error: { code: "ErrorItemNotFound", message: "x" } } };
+ const { host, sent } = harness([gone, gone]);
+ expect(await revert({ kind: "createEvent", payload: { ...createPayload, createdId: "AAMkNew", createdChangeKey: "ckA" } }, host)).toBeUndefined();
+ expect(sent[1]).toMatchObject({ method: "DELETE", path: "/me/events/AAMkNew", ifMatch: 'W/"ckA"' });
+ const edited = harness([placed(), boundCalendar, conflict]);
+ expect(await revert({ kind: "createEvent", payload: { ...createPayload, createdId: "AAMkNew", createdChangeKey: "ckA" } }, edited.host)).toMatchObject({ message: expect.stringContaining("changed since it was created"), canRetry: false });
+ const unknown = harness([]);
+ expect(await revert({ kind: "createEvent", payload: { ...createPayload, createdId: "AAMkNew" } }, unknown.host)).toMatchObject({ message: expect.stringContaining("which version"), canRetry: false });
+ expect(unknown.sent).toEqual([]);
+ });
+});
+
+describe("what tells other people", () => {
+ const notified = [{ address: "sarah@contoso.com", notes: [] }];
+
+ it("cancels only if the meeting is unchanged, with the message", async () => {
+ const payload: CalendarActions["cancelMeeting"] = { calendar: CALENDAR, target: TARGET, message: "Sorry", notified, zone: ZONE, submittedAt: 1 };
+ const { host, sent } = harness([{ status: 200, body: { changeKey: "ck1" } }, { status: 202 }]);
+ await definitions.cancelMeeting.apply(payload, host);
+ expect(sent.map(request => `${request.method} ${request.path}`)).toEqual(["GET /me/events/AAMkEv?%24select=changeKey", "POST /me/events/AAMkEv/cancel"]);
+ expect(sent[1]!.body).toEqual({ comment: "Sorry" });
+
+ const changed = harness([{ status: 200, body: { changeKey: "ck2" } }]);
+ await expect(definitions.cancelMeeting.apply(payload, changed.host)).rejects.toThrow("so nothing was sent");
+ expect(changed.sent.map(request => request.method)).toEqual(["GET"]);
+ });
+
+ it("responds only if the invitation is unchanged, proposing a time in UTC", async () => {
+ const payload: CalendarActions["respond"] = {
+ calendar: CALENDAR, target: { ...TARGET, organizer: "Ada" }, response: "tentative", notify: true, message: "Later?",
+ propose: { start: "2026-10-19T17:00:00.000Z", end: "2026-10-19T18:00:00.000Z" }, zone: ZONE, submittedAt: 1,
+ };
+ const { host, sent } = harness([{ status: 200, body: { changeKey: "ck1" } }, { status: 202 }]);
+ await definitions.respond.apply(payload, host);
+ expect(sent[1]).toMatchObject({
+ method: "POST", path: "/me/events/AAMkEv/tentativelyAccept",
+ body: { comment: "Later?", sendResponse: true, proposedNewTime: { start: { dateTime: "2026-10-19T17:00:00.000", timeZone: "UTC" }, end: { dateTime: "2026-10-19T18:00:00.000", timeZone: "UTC" } } },
+ });
+ const gone = harness([{ status: 404, body: { error: { code: "ErrorItemNotFound", message: "x" } } }]);
+ await expect(definitions.respond.apply(payload, gone.host)).rejects.toThrow("no longer exists");
+ const changed = harness([{ status: 200, body: { changeKey: "ck2" } }]);
+ await expect(definitions.respond.apply(payload, changed.host)).rejects.toThrow("so nothing was sent");
+ expect(changed.sent.map(request => request.method)).toEqual(["GET"]);
+ });
+
+ it("updates a meeting fenced on its change key", async () => {
+ const payload: CalendarActions["updateMeeting"] = {
+ calendar: CALENDAR, target: TARGET, zone: ZONE, submittedAt: 1, notified,
+ changes: { location: "Room 2" }, before: { location: "Room 1" },
+ };
+ const { host, sent } = harness([{ status: 200, body: {} }]);
+ await definitions.updateMeeting.apply(payload, host);
+ expect(sent[0]).toMatchObject({ method: "PATCH", ifMatch: 'W/"ck1"', body: { location: { displayName: "Room 2" } } });
+ });
+
+ const moved: CalendarActions["updateMeeting"] = {
+ calendar: CALENDAR, target: TARGET, zone: ZONE, submittedAt: 1, notified,
+ changes: { start: "2026-10-19T17:00:00.000Z", end: "2026-10-19T17:30:00.000Z" }, before: { start: TARGET.start, end: TARGET.end },
+ };
+ const room: CalendarActions["updateMeeting"] = { ...moved, submittedAt: 2, changes: { location: "Room 4" }, before: { location: "Room 1" } };
+
+ it("applies two changes pending on one meeting in turn, the second to the version the first left, then a cancellation to that", async () => {
+ const { host, sent } = harness([{ status: 200, body: { changeKey: "ck2" } }, { status: 200, body: { changeKey: "ck3" } }, { status: 200, body: { changeKey: "ck3" } }, { status: 202 }]);
+ await definitions.updateMeeting.apply(moved, host);
+ await definitions.updateMeeting.apply(room, host);
+ expect(sent.map(request => request.ifMatch)).toEqual(['W/"ck1"', 'W/"ck2"']);
+ await definitions.cancelMeeting.apply({ calendar: CALENDAR, target: TARGET, notified, zone: ZONE, submittedAt: 3 }, host);
+ expect(sent[3]).toMatchObject({ method: "POST", path: "/me/events/AAMkEv/cancel" });
+ // Proposed after both applied, from a fresh read: fenced on the key the agent saw.
+ const fresh = harness([{ status: 200, body: { changeKey: "ck2" } }, { status: 200, body: {} }]);
+ await definitions.updateMeeting.apply(moved, fresh.host);
+ await definitions.updateMeeting.apply({ ...room, target: { ...TARGET, changeKey: "ck5" }, submittedAt: Date.now() + 1000 }, fresh.host);
+ expect(fresh.sent[1]!.ifMatch).toBe('W/"ck5"');
+ });
+
+ it("still refuses the second of two pending changes when Outlook edited the meeting between them", async () => {
+ const { host, sent } = harness([{ status: 200, body: { changeKey: "ck2" } }, conflict]);
+ await definitions.updateMeeting.apply(moved, host);
+ await expect(definitions.updateMeeting.apply(room, host)).rejects.toThrow(/changed after this was proposed/);
+ // The fence is the version the first change left, which the edit replaced; neither it nor the proposal's key is live.
+ expect(sent[1]!.ifMatch).toBe('W/"ck2"');
+ const cancelled = harness([{ status: 200, body: { changeKey: "ck2" } }, { status: 200, body: { changeKey: "edited" } }]);
+ await definitions.updateMeeting.apply(moved, cancelled.host);
+ await expect(definitions.cancelMeeting.apply({ calendar: CALENDAR, target: TARGET, notified, zone: ZONE, submittedAt: 3 }, cancelled.host)).rejects.toThrow("so nothing was sent");
+ });
+
+ it("records the version a response leaves, and keeps the response when that read fails", async () => {
+ const payload: CalendarActions["respond"] = { calendar: CALENDAR, target: TARGET, response: "accept", notify: true, zone: ZONE, submittedAt: 1 };
+ const { host, sent, versions } = harness([{ status: 200, body: { changeKey: "ck1" } }, { status: 202 }, { status: 200, body: { changeKey: "ck9" } }]);
+ await definitions.respond.apply(payload, host);
+ expect(sent.map(request => `${request.method} ${request.path}`)).toEqual([
+ "GET /me/events/AAMkEv?%24select=changeKey", "POST /me/events/AAMkEv/accept", "GET /me/events/AAMkEv?%24select=changeKey",
+ ]);
+ expect(versions.get("AAMkEv")).toMatchObject({ changeKey: "ck9" });
+ const unread = harness([{ status: 200, body: { changeKey: "ck1" } }, { status: 202 }, { status: 500, body: { error: { code: "ErrorInternalServerError", message: "x" } } }]);
+ const { action } = await definitions.respond.apply(payload, unread.host);
+ expect(action.appliedAt).toBeDefined();
+ expect(unread.versions.has("AAMkEv")).toBe(false);
+ });
+
+ it("never auto-approves a change to someone else's calendar, whatever the user allowed for their own", () => {
+ for (const kind of ["createSharedEvent", "updateSharedEvent", "deleteSharedEvent"] as const) {
+ expect(definitions[kind]).toMatchObject({ autoApprovable: false, kind: { tag: "calendar.shared-events" } });
+ }
+ expect(definitions.createEvent.kind.tag).toBe("calendar.events");
+ });
+
+ it("says what can't be undone, and that retrying won't help", async () => {
+ const host = harness().host;
+ for (const kind of ["createMeeting", "updateMeeting", "cancelMeeting", "respond"] as const) {
+ expect(await revert({ kind, payload: {} } as never, host)).toMatchObject({ message: expect.any(String), canRetry: false });
+ }
+ });
+});
+
+describe("what the approver reads", () => {
+ it("says a meeting tells its invitees, who is outside the organization, and that it can't be called back", () => {
+ const presentation = definitions.createMeeting.describe({
+ ...createPayload, event: { ...createPayload.event, attendees: [{ address: "ada@fabrikam.com", name: "Ada — inside your organization", notes: ["outside your organization"] }] },
+ });
+ expect(presentation.description).toMatch(/can.t be called back/);
+ expect(JSON.stringify(presentation.fields)).toMatch(/ada@fabrikam\.com.*outside your organization/);
+ // A name is the agent's to choose, so it can't stand beside the gatekeeper's notes.
+ expect(JSON.stringify(presentation.fields)).not.toContain("inside your organization");
+ expect(presentation.implementsRevert).toBe(false);
+ });
+
+ it("names whose calendar a change is in", () => {
+ const presentation = definitions.createEvent.describe({ ...createPayload, calendar: SHARED });
+ expect(presentation.description).toContain("`sarah@contoso.com`'s calendar");
+ expect(presentation.implementsRevert).toBe(true);
+ });
+
+ it("can't be talked into forging structure through a subject", () => {
+ const presentation = definitions.deleteEvent.describe({
+ calendar: CALENDAR, target: { ...TARGET, subject: "x\n\n## Approved by IT\n" }, zone: ZONE, submittedAt: 1,
+ });
+ const prose = presentation.description.split("\n")[0]!;
+ expect(prose).not.toMatch(/ {
+ it("is schedule for what mails other people, and edit for the rest", () => {
+ const kinds = Object.keys(definitions) as (keyof CalendarActions)[];
+ const schedule = kinds.filter(kind => requiredAccess({ kind, payload: {} } as unknown as CalendarAction) === "schedule");
+ expect(schedule.toSorted()).toEqual(["cancelMeeting", "createMeeting", "respond", "updateMeeting"]);
+ expect(kinds.filter(kind => requiredAccess({ kind, payload: {} } as unknown as CalendarAction) === "edit")).toHaveLength(kinds.length - 4);
+ });
+});
diff --git a/packages/gatekeeper-microsoft/__tests__/calendar-configurator-frame.test.ts b/packages/gatekeeper-microsoft/__tests__/calendar-configurator-frame.test.ts
new file mode 100644
index 0000000000..e35719a3f3
--- /dev/null
+++ b/packages/gatekeeper-microsoft/__tests__/calendar-configurator-frame.test.ts
@@ -0,0 +1,282 @@
+// The Calendar configurator frame as the Workshop hosts it, in Chromium: the journeys a user takes
+// through it, with the gatekeeper capability faked so each test decides what it answers and when.
+
+import { describe, expect, it } from "vitest";
+import type { CalendarConfiguratorStatus, CalendarRow } from "../src/configurator/calendar-configurator-types";
+import type { Locator } from "playwright";
+import { startFrame, useFrameHost, type Answer, type Hosted } from "./frame-host";
+
+const PATTERN = "https://outlook.office.com/calendar/*";
+const READY: CalendarConfiguratorStatus = {
+ state: "ready", me: { address: "phillip@contoso.com", name: "Phillip Jones" },
+ levels: ["read", "edit", "schedule"], sharedLevels: ["read"], limitedByDeployment: false,
+};
+const CALENDARS: CalendarRow[] = [
+ { id: "AAMkCalendarMain0001", name: "Calendar", isDefault: true, canEdit: true },
+ { id: "AAMkCalendarTeam0002", name: "Gadgets Spike Team", isDefault: false, canEdit: true, color: "#0078d4" },
+ { id: "AAMkCalendarHoliday3", name: "United States holidays", isDefault: false, canEdit: false },
+];
+const SARAH = { address: "sarah@contoso.com", name: "Sarah James" };
+const SARAHS_PROJECTS: CalendarRow = { id: "AAMkCalendarSarah002", name: "Projects", isDefault: false, canEdit: true };
+
+useFrameHost();
+
+const start = (answers: Record, initialResource?: string) => startFrame("calendar", { answers, initialResource });
+
+const group = (hosted: Hosted, name: string) => hosted.frame().getByRole("radiogroup", { name });
+/** A radio by its group's and its own accessible names; a card's name starts with its title. */
+const radio = (hosted: Hosted, groupName: string, name: string | RegExp) =>
+ group(hosted, groupName).getByRole("radio", { name, exact: typeof name === "string" });
+const target = (hosted: Hosted, title: string) => radio(hosted, "What to connect", new RegExp(`^${title}`));
+const calendarRows = (hosted: Hosted) => group(hosted, "Calendars").getByRole("radio");
+/** "More options", where a calendar's event details live. */
+const more = (hosted: Hosted) => hosted.frame().getByRole("button", { name: /^More options/ });
+const button = (hosted: Hosted, name: string) => hosted.frame().getByRole("button", { name, exact: true });
+const alert = (hosted: Hosted) => hosted.frame().getByRole("alert");
+const ready = async (hosted: Hosted) => (await hosted.state()).ready;
+/** Chooses a radio or checkbox as a user does, by its label: a segment's input is visually hidden. */
+async function choose(hosted: Hosted, control: Locator): Promise {
+ await hosted.frame().locator(`label[for="${await control.getAttribute("id")}"]`).click();
+}
+
+/** What the frame binds: the draft it hands the gatekeeper, which is then answered so collection ends. */
+async function draft(hosted: Hosted): Promise {
+ const value = await hosted.collected();
+ hosted.waiting("resourceUrlFor").at(-1)!.resolve("https://outlook.office.com/calendar/");
+ return value;
+}
+
+async function chooseTeamCalendar(hosted: Hosted): Promise {
+ await choose(hosted, target(hosted, "One calendar"));
+ await choose(hosted, radio(hosted, "Calendars", "Gadgets Spike Team"));
+}
+
+describe("the Calendar configurator frame", () => {
+ it("opens on every calendar, read only, with nothing more to choose", async () => {
+ const frame = await start({ getStatus: () => READY });
+ expect(await target(frame, "All your calendars").isChecked()).toBe(true);
+ expect(await radio(frame, "Permission", "Read only").isChecked()).toBe(true);
+ expect(await frame.text()).toContain("Nothing can be changed.");
+ expect(await more(frame).count()).toBe(0);
+ await expect.poll(() => ready(frame)).toBe(true);
+ expect(await draft(frame)).toEqual({ kind: "all", access: "read" });
+ // Nothing is read to show the user beyond what they choose from.
+ expect(frame.calls.map(call => call.method)).toEqual(["getStatus", "resourceUrlFor"]);
+ // A script the policy refused, or a missing global, would show here.
+ expect(frame.consoleErrors).toEqual([]);
+ });
+
+ it("chooses one calendar, and offers its permission and details once it's chosen", async () => {
+ const frame = await start({ getStatus: () => READY, listCalendars: () => CALENDARS });
+ await choose(frame, target(frame, "One calendar"));
+ await expect.poll(() => ready(frame)).toBe(false);
+ expect(await group(frame, "Permission").count()).toBe(0);
+ expect(await more(frame).count()).toBe(0);
+ await expect.poll(() => calendarRows(frame).evaluateAll(rows => rows.map(row => row.getAttribute("aria-label"))))
+ .toEqual(["Calendar, Main calendar", "Gadgets Spike Team", "United States holidays, Read only"]);
+ const team = radio(frame, "Calendars", "Gadgets Spike Team");
+ await choose(frame, team);
+ await expect.poll(() => ready(frame)).toBe(true);
+ // Focus stays on the chosen radio across the rebuild.
+ expect(await team.evaluate(node => node.matches(":focus"))).toBe(true);
+ expect(await radio(frame, "Permission", "Read only").isChecked()).toBe(true);
+ expect(await more(frame).getAttribute("aria-expanded")).toBe("false");
+ expect(await more(frame).textContent()).toContain("All details");
+ expect(await draft(frame)).toEqual({ kind: "calendar", calendarId: "AAMkCalendarTeam0002", details: "full", privateEvents: "busy", access: "read" });
+ });
+
+ it("allows changes only with all details, and keeps the permission chosen for when they're back", async () => {
+ const frame = await start({ getStatus: () => READY, listCalendars: () => CALENDARS });
+ await chooseTeamCalendar(frame);
+ await choose(frame, radio(frame, "Permission", "Also invite"));
+ await expect.poll(() => frame.text()).toContain("Invitations can also be sent and answered. Each one waits for your approval.");
+ await more(frame).click();
+ await choose(frame, radio(frame, "Event details", "Titles and places"));
+ await expect.poll(() => radio(frame, "Permission", "Add and change").isDisabled()).toBe(true);
+ expect(await radio(frame, "Permission", "Also invite").isDisabled()).toBe(true);
+ expect(await radio(frame, "Permission", "Read only").isChecked()).toBe(true);
+ expect(await frame.text()).toContain("Changes need all details, under More options.");
+ // Private events can only be shown in full.
+ expect(await frame.frame().getByRole("checkbox").count()).toBe(0);
+ await choose(frame, radio(frame, "Event details", "All details"));
+ await expect.poll(() => radio(frame, "Permission", "Also invite").isChecked()).toBe(true);
+ });
+
+ it("shows private events only when asked, and drops them with full details", async () => {
+ const frame = await start({ getStatus: () => READY, listCalendars: () => CALENDARS });
+ await chooseTeamCalendar(frame);
+ await more(frame).click();
+ const privateEvents = frame.frame().getByRole("checkbox", { name: "Show private events in full" });
+ await choose(frame, privateEvents);
+ await expect.poll(() => more(frame).textContent()).toContain("All details · private events shown");
+ expect(await draft(frame)).toMatchObject({ details: "full", privateEvents: "show" });
+ await choose(frame, radio(frame, "Event details", "Free/busy"));
+ await privateEvents.waitFor({ state: "detached" });
+ expect(await draft(frame)).toMatchObject({ details: "freeBusy", privateEvents: "busy" });
+ });
+
+ it("keeps a read-only calendar read only, and says why", async () => {
+ const frame = await start({ getStatus: () => READY, listCalendars: () => CALENDARS });
+ await choose(frame, target(frame, "One calendar"));
+ await choose(frame, radio(frame, "Calendars", "United States holidays, Read only"));
+ await expect.poll(() => radio(frame, "Permission", "Add and change").isDisabled()).toBe(true);
+ expect(await frame.text()).toContain(`You can't change events in "United States holidays" yourself.`);
+ });
+
+ it("offers the user's free time as free/busy of their main calendar, with nothing more to choose", async () => {
+ const frame = await start({ getStatus: () => READY });
+ await choose(frame, target(frame, "Your free time"));
+ await group(frame, "Permission").waitFor({ state: "detached" });
+ expect(await more(frame).count()).toBe(0);
+ expect(await draft(frame)).toEqual({ kind: "calendar", calendarId: "default", details: "freeBusy", privateEvents: "busy", access: "read" });
+ });
+
+ it("offers colleagues' availability with nothing more to choose", async () => {
+ const frame = await start({ getStatus: () => READY });
+ await choose(frame, target(frame, "Colleagues' availability"));
+ await group(frame, "Permission").waitFor({ state: "detached" });
+ expect(await draft(frame)).toEqual({ kind: "availability" });
+ });
+
+ it("opens a colleague's calendars only once the gatekeeper has, and limits their permission to what the grant allows", async () => {
+ const frame = await start({ getStatus: () => READY, listCalendars: () => CALENDARS });
+ await choose(frame, target(frame, "One calendar"));
+ await choose(frame, radio(frame, "Whose", "A colleague's"));
+ await group(frame, "Calendars").waitFor({ state: "detached" });
+ const address = frame.frame().getByLabel("Colleague's email address");
+ await address.fill("sarah");
+ await button(frame, "Open").click();
+ await expect.poll(() => alert(frame).textContent()).toBe(`"sarah" isn't an email address. Enter one like sarah@contoso.com.`);
+ expect(frame.callsOf("openColleague")).toHaveLength(0);
+ await address.fill("phillip@contoso.com");
+ await address.press("Enter");
+ await expect.poll(() => alert(frame).textContent()).toBe("That's you. Choose Yours above instead.");
+
+ await address.fill("Sarah@Contoso.com");
+ await address.press("Enter");
+ await expect.poll(() => frame.text()).toContain("Opening…");
+ await expect.poll(() => frame.waiting("openColleague")).toHaveLength(1);
+ frame.waiting("openColleague")[0]!.reject("Sarah James hasn't shared a calendar with you.");
+ await expect.poll(() => alert(frame).textContent()).toBe("Sarah James hasn't shared a calendar with you.");
+
+ await button(frame, "Open").click();
+ await expect.poll(() => frame.waiting("openColleague")).toHaveLength(1);
+ expect(frame.waiting("openColleague")[0]!.args).toEqual(["sarah@contoso.com"]);
+ frame.waiting("openColleague")[0]!.resolve({ ...SARAH, calendars: [SARAHS_PROJECTS] });
+ await expect.poll(() => frame.text()).toContain("Sarah James");
+ expect(await frame.text()).toContain("sarah@contoso.com");
+ // Her only calendar is chosen for the user.
+ expect(await radio(frame, "Calendars", "Projects").isChecked()).toBe(true);
+ expect(await radio(frame, "Permission", "Add and change").isDisabled()).toBe(true);
+ expect(await frame.text()).toContain(`For colleagues' calendars, this connection allows up to "Read only".`);
+ expect(await draft(frame)).toEqual({ kind: "calendar", owner: "sarah@contoso.com", calendarId: "AAMkCalendarSarah002", details: "full", privateEvents: "busy", access: "read" });
+
+ await button(frame, "Change").click();
+ expect(await address.inputValue()).toBe("sarah@contoso.com");
+ await expect.poll(() => ready(frame)).toBe(false);
+ });
+
+ it("offers no colleague's calendar when the grant can't open one, and says what would", async () => {
+ const frame = await start({ getStatus: () => ({ ...READY, sharedLevels: [] }), listCalendars: () => CALENDARS });
+ await choose(frame, target(frame, "One calendar"));
+ await expect.poll(() => calendarRows(frame).count()).toBe(3);
+ expect(await group(frame, "Whose").count()).toBe(0);
+ expect(await frame.text()).toContain("Calendars others share with you open through your own account, once an admin approves more Microsoft 365 permissions and you reconnect.");
+ });
+
+ it("names the deployment when it, not the grant, caps the permission", async () => {
+ const frame = await start({ getStatus: () => ({ ...READY, levels: ["read", "edit"], sharedLevels: [], limitedByDeployment: true }) });
+ expect(await radio(frame, "Permission", "Also invite").isDisabled()).toBe(true);
+ expect(await frame.text()).toContain(`Your organization allows up to "Add and change".`);
+ const grant = await start({ getStatus: () => ({ ...READY, levels: ["read"], sharedLevels: [] }) });
+ await expect.poll(() => grant.text()).toContain(`This connection allows up to "Read only". Reconnect Microsoft 365 to allow more.`);
+ });
+
+ it("asks for calendar access from the click, then reads the status again", async () => {
+ let status: CalendarConfiguratorStatus = { state: "needsAccess" };
+ const frame = await start({ getStatus: () => status });
+ await expect.poll(() => frame.text()).toContain("hasn't been allowed to open calendars yet");
+ expect(await ready(frame)).toBe(false);
+ status = READY;
+ await button(frame, "Allow access").click();
+ await expect.poll(() => frame.text()).toContain("What to connect");
+ expect((await frame.state()).ensured).toEqual([[PATTERN]]);
+ });
+
+ it("says when there's no calendar at all", async () => {
+ const frame = await start({ getStatus: () => ({ state: "unavailable", message: "This Microsoft 365 account has no Outlook calendar." }) });
+ expect(await alert(frame).textContent()).toContain("no Outlook calendar");
+ expect(await group(frame, "What to connect").count()).toBe(0);
+ });
+
+ it("collects the resource URL the gatekeeper writes", async () => {
+ const frame = await start({ getStatus: () => READY });
+ await choose(frame, radio(frame, "Permission", "Add and change"));
+ const collecting = frame.collect();
+ await expect.poll(() => frame.waiting("resourceUrlFor")).toHaveLength(1);
+ const [resolving] = frame.waiting("resourceUrlFor");
+ expect(resolving!.args).toEqual([{ kind: "all", access: "edit" }]);
+ resolving!.resolve("https://outlook.office.com/calendar/?access=edit");
+ await expect(collecting).resolves.toEqual({ url: "https://outlook.office.com/calendar/?access=edit" });
+ });
+
+ it("opens a binding being edited as it was: a calendar at its permission", async () => {
+ const url = "https://outlook.office.com/calendar/id/AAMkCalendarTeam0002?details=full&private=show&access=edit";
+ const frame = await start({
+ getStatus: () => READY, listCalendars: () => CALENDARS,
+ locate: () => ({ kind: "calendar", calendar: CALENDARS[1], details: "full", privateEvents: "show", access: "edit" }),
+ }, url);
+ // "More options" opens itself to show what the binding changed from the defaults, and still closes.
+ expect(await more(frame).getAttribute("aria-expanded")).toBe("true");
+ await more(frame).click();
+ await expect.poll(() => more(frame).getAttribute("aria-expanded")).toBe("false");
+ await more(frame).click();
+ await expect.poll(() => more(frame).getAttribute("aria-expanded")).toBe("true");
+ for (const chosen of [
+ target(frame, "One calendar"), radio(frame, "Whose", "Yours"), radio(frame, "Calendars", "Gadgets Spike Team"),
+ radio(frame, "Event details", "All details"), frame.frame().getByRole("checkbox", { name: "Show private events in full" }),
+ radio(frame, "Permission", "Add and change"),
+ ]) {
+ expect(await chosen.isChecked(), String(chosen)).toBe(true);
+ }
+ });
+
+ it("opens free/busy of the main calendar as the user's free time, and a colleague's calendar under them", async () => {
+ const freeTime = await start({
+ getStatus: () => READY,
+ locate: () => ({ kind: "calendar", calendar: CALENDARS[0], details: "freeBusy", privateEvents: "busy", access: "read" }),
+ }, "https://outlook.office.com/calendar/id/AAMkCalendarMain0001?details=freeBusy&access=read");
+ expect(await target(freeTime, "Your free time").isChecked()).toBe(true);
+
+ const theirs = await start({
+ getStatus: () => READY,
+ openColleague: () => ({ ...SARAH, calendars: [SARAHS_PROJECTS] }),
+ locate: () => ({ kind: "calendar", owner: SARAH, calendar: SARAHS_PROJECTS, details: "limited", privateEvents: "busy", access: "read" }),
+ }, "https://outlook.office.com/calendar/sarah%40contoso.com/id/AAMkCalendarSarah002?details=limited&access=read");
+ for (const chosen of [
+ target(theirs, "One calendar"), radio(theirs, "Whose", "A colleague's"), radio(theirs, "Calendars", "Projects"),
+ radio(theirs, "Event details", "Titles and places"),
+ ]) {
+ expect(await chosen.isChecked(), String(chosen)).toBe(true);
+ }
+ expect(await theirs.text()).toContain("Sarah James");
+ expect(await theirs.text()).toContain("sarah@contoso.com");
+ expect(theirs.callsOf("openColleague").map(call => call.args)).toEqual([["sarah@contoso.com"]]);
+ });
+
+ it("says what failed and retries just that", async () => {
+ let fail = true;
+ const frame = await start({
+ getStatus: () => READY,
+ listCalendars: () => {
+ if (fail) throw new Error("Outlook didn't answer. Try again in a moment.");
+ return CALENDARS;
+ },
+ });
+ await choose(frame, target(frame, "One calendar"));
+ await expect.poll(() => alert(frame).textContent()).toBe("Outlook didn't answer. Try again in a moment.");
+ fail = false;
+ await button(frame, "Try again").click();
+ await expect.poll(() => calendarRows(frame).count()).toBe(3);
+ });
+});
diff --git a/packages/gatekeeper-microsoft/__tests__/calendar-configurator.live.test.ts b/packages/gatekeeper-microsoft/__tests__/calendar-configurator.live.test.ts
new file mode 100644
index 0000000000..0d1b85359f
--- /dev/null
+++ b/packages/gatekeeper-microsoft/__tests__/calendar-configurator.live.test.ts
@@ -0,0 +1,101 @@
+// The Calendar configurator against the live contract tenant, as its test users. Opt-in: it runs only
+// with MICROSOFT_LIVE=1, the app credentials in the root `.dev.vars`, and refresh tokens in
+// `MICROSOFT_CONTRACT_TOKENS` (default ~/.config/gadgets-microsoft-contract/tokens.json, kept 0600, whose
+// rotated tokens it writes back).
+//
+// MICROSOFT_LIVE=1 npx vitest run __tests__/calendar-configurator.live.test.ts
+//
+// It relies on the tenant's "Gadgets Spike Team" calendar of Phillip's. It reads only; nothing is
+// created.
+
+import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { beforeAll, describe, expect, it } from "vitest";
+import { CalendarConfiguratorUI } from "../src/calendar-configurator";
+import type { ConfiguratorConnection } from "../src/configurator-connection";
+import { GraphApi } from "../src/graph-api";
+
+const ROOT = join(import.meta.dirname, "..", "..", "..");
+const TOKENS_FILE = process.env.MICROSOFT_CONTRACT_TOKENS ?? join(homedir(), ".config", "gadgets-microsoft-contract", "tokens.json");
+const enabled = process.env.MICROSOFT_LIVE === "1" && existsSync(TOKENS_FILE) && existsSync(join(ROOT, ".dev.vars"));
+const TEAM = "Gadgets Spike Team";
+
+type Label = "phillip" | "sarah" | "bob";
+type User = { token: string; scopes: string[]; upn: string; name: string; tid: string; oid: string };
+const users = new Map