/packs / microsoft

microsoft

SharePoint and OneDrive documents through Microsoft Graph, read as one Entra app.

  • integration
  • sources
  • Microsoft

As of yardr-packs at commit a2b86e6, 11 Nov 2026. The pack's own files keep the author's words: issue · bead, repo · depot, workspace · yard, agents · crew, agent · crew member.

01 / apply it

yardr pack apply ./microsoft

From a clone of yardr-packs: the pack's directory. yardr pack check ./microsoft shows what it would change first. It goes into the workspace itself, and into no repo · depot.

yardr pack apply https://yardr.dev/packs/microsoft/

From this site, as the files below.

Lives in microsoft/ at a2b86e6 · SETUP.md

02 / the files

Every file, as it ships.

# Microsoft 365 documents as a pack of kind source: the integration,
# imported into the yard and into no depot. What Microsoft is here: the
# declared source microsoft, which lists SharePoint sites, a site's
# libraries and a library's folders, reads an item's metadata and link,
# and searches documents, all through Microsoft Graph as one Entra app.
# Its credential mints from the app's tenant, id and secret, by
# microsoft-mint, the mint command the pack brings beside its
# declaration. Read only; it downloads no file bytes, though search hits
# may carry short text excerpts from Graph.
#
#   yardr pack apply /Users/mckean/projects/repos/yardr-packs/microsoft
#
# The source is named microsoft: a pack is named after the service it
# talks to. A pack of kind source asks no grants at import, so what a
# crew member may call is granted afterwards, by the setup notes. The
# app reads whatever it was consented to, one identity for the yard, so
# every agent granted a call reads all of it.
name: microsoft
kind: source
setup: SETUP.md

sources:
  microsoft:
    declaration: microsoft.yaml

# Suggested grants: named sets of the source's calls, printed numbered by
# pack show. Suggesting grants nothing: the operator grants them, and
# terminal pack apply ignores them.
presets:
  read:
    about: List sites, libraries and folders, search documents and read an item's metadata and link; change nothing
    grants:
      microsoft: [read]
#!/bin/sh
# Mints a Microsoft Graph access token for an Entra app: the mint command
# the microsoft pack's declaration brings (credential.mint_command), whose
# credential names MICROSOFT_TENANT, MICROSOFT_CLIENT_ID and
# MICROSOFT_CLIENT_SECRET under credential.mint. Applying the pack copies
# it under <home>/sources/microsoft/; the core runs it with {inputs,
# scope} on stdin and reads {token, expires_at} on stdout.
#
# The grant is OAuth 2.0 client credentials against Microsoft's v2.0
# endpoint, as Microsoft's page on the flow writes it: POST
# https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token with the
# form client_id, scope=https://graph.microsoft.com/.default,
# client_secret and grant_type=client_credentials. The token is the app's
# own and carries the application permissions an administrator consented
# to. The scope on stdin is the call's; Entra narrows by consent, not by
# call, so it is read and ignored.
#
# The secret reaches the token request and nothing else. jq reads it from
# stdin, since the core writes stdin with Go's json.Marshal, which turns
# an ampersand into a backslash-u escape (and < and > the same), and only
# a JSON reader turns that back. It goes on to
# curl's stdin through pipes of printf, a builtin of every sh yardr runs
# on, so it is in no argument, no file and no heredoc. No message below
# repeats a value of the inputs or a word of Entra's answer: a failure
# says the HTTP status, Entra's error if it is one short lower-case word,
# and the number in error_codes (70011 is AADSTS70011).
#
# Needs curl and jq on the PATH the yard's server has. curl reaches
# login.microsoftonline.com through the proxy and CA variables the core
# hands on (HTTPS_PROXY, https_proxy, NO_PROXY, no_proxy, SSL_CERT_FILE,
# SSL_CERT_DIR), and this script gives it no option that overrides them:
# no --proxy, --noproxy, --cacert or -k. curl does not read
# NODE_EXTRA_CA_CERTS, a Node variable. Reading it as --cacert would
# replace the system's roots where Node adds to them, so it is left alone.
set -eu

fail() { echo "microsoft-mint: $*" >&2; exit 1; }

command -v curl >/dev/null 2>&1 || fail "needs curl on PATH"
command -v jq >/dev/null 2>&1 || fail "needs jq on PATH"

input=$(cat)
[ -n "$input" ] || fail "reads {inputs, scope} on stdin, and got nothing"

# jq's own messages are dropped throughout: one may quote the value it
# choked on, and a value here may be the secret.
printf '%s' "$input" | jq -e '.inputs | type == "object"' >/dev/null 2>&1 ||
  fail "reads {inputs, scope} on stdin, and got no inputs"
for name in MICROSOFT_TENANT MICROSOFT_CLIENT_ID MICROSOFT_CLIENT_SECRET; do
  printf '%s' "$input" | jq -e --arg n "$name" '.inputs[$n] | strings | length > 0' >/dev/null 2>&1 ||
    fail "its inputs name no $name"
done

# The tenant is a path segment of the token address, a GUID or a domain
# name, so nothing that could leave the segment is let through.
tenant=$(printf '%s' "$input" | jq -j '.inputs.MICROSOFT_TENANT' 2>/dev/null) ||
  fail "cannot read MICROSOFT_TENANT"
case $tenant in
  ''|-*|.*|*[!A-Za-z0-9.-]*) fail "MICROSOFT_TENANT is no tenant id or domain name" ;;
esac

# The form in the order Microsoft's page writes it. @uri percent-encodes
# what is interpolated, which Microsoft asks of the secret, and leaves
# the text around it as written.
form=$(printf '%s' "$input" | jq -j '@uri "client_id=\(.inputs.MICROSOFT_CLIENT_ID)&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&client_secret=\(.inputs.MICROSOFT_CLIENT_SECRET)&grant_type=client_credentials"' 2>/dev/null) ||
  fail "cannot write the token request"

# The answer is read with its status after it, split at the last line
# break by the shell itself. The core ends this command at 30 seconds.
nl='
'
resp=$(printf '%s' "$form" | curl --silent --max-time 20 --proto '=https' --tlsv1.2 \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-binary @- --write-out '\n%{http_code}' \
  "https://login.microsoftonline.com/$tenant/oauth2/v2.0/token") || {
  rc=$?
  case $rc in
    5|6|7) why="no connection; behind a proxy, HTTPS_PROXY names it" ;;
    28) why="no answer in 20 seconds" ;;
    35|60|77) why="TLS failed; a proxy that inspects TLS needs its CA in SSL_CERT_FILE" ;;
    *) why= ;;
  esac
  fail "could not reach login.microsoftonline.com (curl exit $rc${why:+, $why})"
}
code=${resp##*"$nl"}
body=${resp%"$nl"*}
case $code in
  [0-9][0-9][0-9]) ;;
  *) fail "the token endpoint answered with no status" ;;
esac

if [ "$code" != 200 ]; then
  detail=$(printf '%s' "$body" | jq -r '[(.error | strings | select(test("^[a-z_]{1,40}$"))), (.error_codes[0]? | numbers | "AADSTS\(.)")] | join(", ")' 2>/dev/null) || detail=
  fail "the token endpoint answered $code${detail:+ ($detail)}"
fi

# A token is one line of the characters a JWT is written in, and the
# expiry is now plus expires_in, which Entra writes as a number (and the
# older endpoint as a string). The core reads expires_at as RFC 3339.
out=$(printf '%s' "$body" | jq -ce '
  select((.access_token | type) == "string" and (.access_token | test("^[A-Za-z0-9._~+/=-]+$"))
         and ((.expires_in | tonumber? // 0) > 0))
  | {token: .access_token, expires_at: ((now + (.expires_in | tonumber)) | floor | todate)}' 2>/dev/null) ||
  fail "the token endpoint answered 200 with no token"
printf '%s\n' "$out"
# Microsoft 365 documents as a declared source: SharePoint sites searched
# by name, a site's libraries listed, a library's folders and files
# listed, one item's metadata and link read, and documents searched by
# words. All of it goes through Microsoft Graph as one Entra app, with no
# person signed in, so the app's consent decides what is read and every
# agent granted a call reads all of it. What it never does: no write of
# any kind, no full file bytes (no call here downloads a file's bytes; a
# search hit may still include Graph's short text excerpt), no Outlook or
# Teams, no reading as each person, no delta sync, and no paging. An
# answer that has a next page keeps
# @odata.nextLink as the sign of more and follows none; search pages with
# from and size, which its body can say.
#
# Checked against Microsoft's Graph and Entra documentation on 2
# November 2026, not against a tenant:
#   sites: GET /sites?search={query} takes the application permission
#     Sites.Read.All (Sites.ReadWrite.All above it). The page says the
#     method "doesn't support the Sites.Selected application permission".
#   drives, root, children, item: GET /sites/{id}/drives, GET
#     /drives/{id}/root/children, GET /drives/{id}/items/{id}/children and
#     GET /drives/{id}/items/{id} take Files.Read.All (Sites.Read.All above
#     it) and each supports $select. Their tables do not name
#     Sites.Selected. The overview of the Selected permissions says that
#     "the destination to be accessed controls the behavior", whatever
#     Graph path reaches it, so a site granted to the app with the role
#     read should answer these four.
#     No items/root alias is documented; the top of a library is the
#     drive's root relationship, hence the call root.
#   search: POST /search/query with entityTypes driveItem takes Files.Read.All
#     (Sites.Read.All above it); Sites.Selected is not listed. With
#     application permissions the request must carry region, "Required
#     for searches that use application permissions": the tenant's data
#     location code, siteCollection.dataLocationCode, which v1.0 says is
#     "Only present for multi-geo tenants". Application search covers
#     shared content only; private content needs sharePointOneDriveOptions
#     and an index that can take days to build, so the body leaves it out.
#     size is at most 500, and a from past 1000 is not supported. The
#     default hit carries name, webUrl and parentReference (siteId,
#     driveId); the fields property would replace those with list columns,
#     so the body names none.
#
# A driveItem has @microsoft.graph.downloadUrl, a link Microsoft calls
# preauthenticated: it needs no sign-in and lives an hour, so it would
# hand an agent a file's bytes outside any grant. Microsoft's page on
# downloading a file has an app get it by selecting it by name
# (select=id,@microsoft.graph.downloadUrl); the pages read do not say what
# a request without $select returns. These requests send $select with
# their own fields and never name the link. select keeps top-level keys
# only and cannot reach into value[], so for root and children the
# $select is all that keeps the link out; item's select drops it as well.
# An answer is read whole up to 1 MiB and what select keeps is bounded by
# answer.max, 64 KB unless raised; the lists raise it, since an item
# with these fields is about 650 bytes, so 50 are about 32 KB and Graph's
# own page of 200 about 130 KB.
#
# The credential mints: MICROSOFT_TENANT and MICROSOFT_CLIENT_ID name the
# tenant and the app (neither is a secret, so both are asked in the
# clear) and MICROSOFT_CLIENT_SECRET is the app's secret, which
# microsoft-mint, beside this file, turns into the app's token by the
# client credentials grant. The operator's own mint_command wins over it.
# The file names no check call: which call works depends on the
# permission the app was given (sites and search fail under
# Sites.Selected), so any fixed one would call a good setup broken. A
# mint is made only for a check call, so `yardr source check` here
# reports where the three variables are set and sends nothing; the
# first call is the test (SETUP.md).
name: microsoft
about: SharePoint and OneDrive documents through Microsoft Graph, read as one Entra app; sites, libraries and folders listed, items read, documents searched.
base: https://graph.microsoft.com/v1.0
credential:
  env: MICROSOFT_TOKEN
  header: "Authorization: Bearer {credential}"
  mint:
    - name: MICROSOFT_TENANT
      secret: false               # a tenant id or domain, asked in the clear
    - name: MICROSOFT_CLIENT_ID
      secret: false               # the app's id, asked in the clear
    - MICROSOFT_CLIENT_SECRET
  mint_command: [microsoft-mint]    # the file beside this one; the source's own mint_command wins
calls:
  sites:
    kind: read
    about: Search sites by name; answers each site's id, which drives takes. Needs Sites.Read.All on the app.
    params:
      query: { type: string, max: 200 }   # free text: its value is in no event
    request:
      method: GET
      path: /sites
      query: { search: "{query}" }
    answer: { select: [value, "@odata.nextLink"], max: 262144 }   # nextLink says there is more; paging is left out
  drives:
    kind: read
    about: List a site's document libraries; answers each library's id, which root and children take.
    params:
      site: { type: string, pattern: "^[A-Za-z0-9][A-Za-z0-9._,-]*$", max: 300, log: true }   # contoso.sharepoint.com,<guid>,<guid>
    request:
      method: GET
      path: /sites/{site}/drives
      query: { $select: "id,name,webUrl,driveType,description" }
    answer: { select: [value, "@odata.nextLink"], max: 262144 }
  root:
    kind: read
    about: List the top level of a library, one page; answers each item's id, name, webUrl and size, and childCount for a folder.
    params:
      drive: { type: string, pattern: "^[A-Za-z0-9!_-]+$", max: 200, log: true }
      top: { type: int, default: 50 }   # items in the page; Graph's own page is 200
    request:
      method: GET
      path: /drives/{drive}/root/children
      query: { $select: "id,name,webUrl,size,lastModifiedDateTime,file,folder,parentReference", $top: "{top}" }
    answer: { select: [value, "@odata.nextLink"], max: 262144 }
  children:
    kind: read
    about: List the items in a folder, one page; answers what root does for a folder's id.
    params:
      drive: { type: string, pattern: "^[A-Za-z0-9!_-]+$", max: 200, log: true }
      item: { type: string, pattern: "^[A-Za-z0-9!_-]+$", max: 200, log: true }
      top: { type: int, default: 50 }
    request:
      method: GET
      path: /drives/{drive}/items/{item}/children
      query: { $select: "id,name,webUrl,size,lastModifiedDateTime,file,folder,parentReference", $top: "{top}" }
    answer: { select: [value, "@odata.nextLink"], max: 262144 }
  item:
    kind: read
    about: Read one item's metadata and its webUrl, the link a person opens.
    params:
      drive: { type: string, pattern: "^[A-Za-z0-9!_-]+$", max: 200, log: true }
      item: { type: string, pattern: "^[A-Za-z0-9!_-]+$", max: 200, log: true }
    request:
      method: GET
      path: /drives/{drive}/items/{item}
      query: { $select: "id,name,webUrl,size,lastModifiedDateTime,file,folder,parentReference" }   # leaves out @microsoft.graph.downloadUrl
    answer: { select: [id, name, webUrl, size, lastModifiedDateTime, file, folder, parentReference] }
  search:
    kind: read
    about: Search documents by words; answers hits with name, webUrl and parentReference, and may include Graph's short text excerpt in summary. Needs Files.Read.All or Sites.Read.All on the app, and region, the tenant's data location code (NAM, EUR, APC). Finds shared content only.
    params:
      query: { type: string, max: 500 }   # free text, Microsoft's query syntax: its value is in no event
      region: { type: string, pattern: "^[A-Z]{3}$" }   # Microsoft's PreferredDataLocation code: NAM, EUR, GBR
      size: { type: int, default: 10 }   # hits in the page, 500 at most
      from: { type: int, default: 0 }   # hits to skip, 1000 at most
    request:
      method: POST
      path: /search/query
      body:
        requests:
          - entityTypes: [driveItem]
            query: { queryString: "{query}" }
            region: "{region}"
            from: "{from}"
            size: "{size}"
    answer: { select: [value], max: 262144 }
# Microsoft

SharePoint and OneDrive documents through Microsoft Graph, read as one
Entra app. As the declared source `microsoft`, an agent lists a tenant's
sites, a site's document libraries, and the folders and files of a
library, reads one item's metadata and its link, and searches documents
by words. Calls are read-only. Search hits can include short excerpts
from matching documents in Graph's `summary` field; no call downloads
full file bytes. Nothing here is compiled into yardr; apply it with:

```sh
yardr pack apply /Users/mckean/projects/repos/yardr-packs/microsoft
```

from main of this repository. SETUP.md is the operator's steps, written
for whoever runs Microsoft 365 for the team.

## One identity for the yard

The yard reads as an Entra app with no person signed in. Whatever the
app may read, every agent granted a call of this source reads, and the
yard does not narrow it per person. SETUP.md says this before its first
step and again at the grant, and sets the narrow permission
(Sites.Selected, granted site by site) beside the broad ones.

## The calls

All of kind read, and one preset, `read`, grants them. The permissions
are the application permissions Microsoft's pages list for each request,
read on 2 November 2026.

| call | Graph request | parameters | permission |
|---|---|---|---|
| `sites` | `GET /sites?search={query}` | `query` | Sites.Read.All. Microsoft: "doesn't support the Sites.Selected application permission" |
| `drives` | `GET /sites/{site}/drives` | `site` | Files.Read.All or Sites.Read.All, or Sites.Selected on a granted site |
| `root` | `GET /drives/{drive}/root/children` | `drive`, `top` (50) | as `drives` |
| `children` | `GET /drives/{drive}/items/{item}/children` | `drive`, `item`, `top` (50) | as `drives` |
| `item` | `GET /drives/{drive}/items/{item}` | `drive`, `item` | as `drives` |
| `search` | `POST /search/query`, entityTypes `driveItem` | `query`, `region`, `size` (10), `from` (0) | Files.Read.All or Sites.Read.All. Sites.Selected is not listed |

What is behind the table:

- `search` is a POST and a read. yardr refuses only PUT, PATCH and
  DELETE on a read call.
- `root` is a call of its own because Microsoft documents no
  `items/root` alias. A library's top level is the drive's `root`
  relationship, and without the call an agent could list libraries and
  not open one.
- Microsoft requires `region` of a search made with application
  permissions: the tenant's data location code, such as `NAM`. Graph
  gives it as `dataLocationCode` for a Multi-Geo tenant and for no
  other, so the call takes it as a parameter and SETUP.md says where to
  find it. App-only search covers shared content only. The request names
  no `sharePointOneDriveOptions`, because private content needs an
  index Microsoft builds over days.
- Every driveItem request sends `$select` with its own fields.
  A driveItem has `@microsoft.graph.downloadUrl`, a link Microsoft calls
  preauthenticated, which needs no sign-in and lives an hour, so it would
  give an agent a file's bytes outside any grant. Microsoft's page on
  downloading a file has an app get the link by selecting it by name, and
  these requests never name it. A call's `select` keeps top-level keys
  only, so for `root` and `children` the `$select` is all that keeps the
  link out, and `item` drops it a second time. The pages read do not say
  what a request with no `$select` returns.
- The `Sites.Selected` column rests on Microsoft's overview of the
  Selected permissions, which says the granted resource decides access
  whichever Graph path reaches it. The pages for these requests do not
  name it. Nothing here has run against a tenant.
- There is no paging. A page that has more keeps `@odata.nextLink` and
  follows none; `search` pages with `from` and `size`. A list's answer
  may be 256 KiB, `answer.max` raised from the 64 KB default, since an
  item with these fields is about 650 bytes and Graph's own page of 200
  is about 130 KB.
- The file names no check call, because which call works depends on the
  permission. yardr makes a mint only for a check call, so `yardr source
  check microsoft` reports where the three values are set and sends
  nothing. The first call is the test (SETUP.md, step 9).
- yardr hands an agent the status of a failed call and not Graph's error
  body.

## The mint

`microsoft-mint` turns the tenant, the app's id and the app's secret
into a token by the OAuth client credentials grant, as Microsoft's page
on the flow writes it: a POST of `client_id`, `scope`
(`https://graph.microsoft.com/.default`), `client_secret` and
`grant_type` to `https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token`.
It answers `{token, expires_at}`, and the yard keeps the token in
memory alone.

The secret goes from stdin through jq to curl's stdin. It is in no
argument, no file and no message. A failure says the HTTP status,
Entra's error if it is one short lower-case word, and the number in
`error_codes`, so a wrong secret reads `answered 401 (invalid_client,
AADSTS7000215)` and never repeats Entra's description. The tenant is
held to letters, digits, dots and dashes, since it is a path segment of
the token address.

The yard passes these proxy and CA variables to a command:
`HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`, `http_proxy`, `NO_PROXY`,
`no_proxy`, `SSL_CERT_FILE` and `SSL_CERT_DIR`. For this HTTPS endpoint,
curl uses `HTTPS_PROXY` or `https_proxy`, and `NO_PROXY` or `no_proxy`
can bypass it; `SSL_CERT_FILE` and `SSL_CERT_DIR` provide CA roots.
curl ignores uppercase `HTTP_PROXY`, and `http_proxy` applies to HTTP
URLs. The script gives curl no option that overrides these variables.
`NODE_EXTRA_CA_CERTS` is a Node variable, and tried the same way it did
nothing to curl. Reading it as `--cacert` would replace the system's
roots where Node adds to them, so a company CA belongs in
`SSL_CERT_FILE`.

## Files

- `pack.yaml`, `microsoft.yaml`: the pack, its one preset and its
  declaration. The declaration's header says what was checked against
  Microsoft's pages and what was not.
- `microsoft-mint`: the mint, POSIX sh with curl and jq.
- `SETUP.md`: the Entra app registration, the permission choice, the
  grants and the first call.
- `test`, `testdata/`: the tests. `calls/` holds a fixture for each
  call (parameters, the request it must make, a recorded Graph answer,
  what the call keeps, and parameters it must refuse); `render.jq`
  builds the request from the declaration; `mint/` holds the mint's
  cases and its golden stdin, curl arguments and form; `bin/` holds the
  stand-in curl and the jq that logs its arguments.

## Runtimes

- The mint wants POSIX sh, curl 7.34 or later and jq 1.6 or later on
  the PATH the yard's server has. Its 39 cases pass with the mint run
  by macOS's `/bin/sh`, dash, ksh and bash.
- The test wants jq 1.6 or later, and ruby, python3 with PyYAML, or yq
  to read `microsoft.yaml` as data. With none of them it fails and says
  so, since a skip would leave the requests unpinned. It wants no
  network and no secret, and runs in a few seconds.

## Testing

```sh
./test
```

in this directory. For each call, `render.jq` builds from the
declaration the request yardr sends for the fixture's parameters, the
way yardr's `internal/source/call.go` builds it, and what the call keeps
of the recorded answer, and the test compares both with the fixture,
which was written from Microsoft's examples and not from the
declaration. It then compares the call names, kinds, base and mint
command with what `yardr pack check` reads of the same file.

For the mint, each of 39 cases runs the script itself under a bare
environment, with a stand-in curl that records its arguments, stdin and
environment and answers from the case. The test compares the token,
the message and the exit status; the curl arguments and the form
against `testdata/mint/request.*`; and the proxy and CA variables
against those set. For every case it looks for the secret in the
output and in every argument of curl and jq, as it is, percent-encoded,
as stdin carries it, and by its longest run of letters and digits, and
it checks that no file was written. The golden secret has an ampersand,
angle brackets, quotes, a backslash, a plus, a slash, a percent sign, a
space and non-ASCII characters in it.

For the grant, the test reads the `source grant` commands SETUP.md
teaches (step 8's, and the `--run` of its card) and runs each as written
against the yardr on PATH, in a scratch yard of its own with the pack
applied from its directory, as step 6 says. Each must be accepted. yardr
takes `--preset` only for a pack inside its own binary, so a SETUP that
teaches it fails there, with yardr's reason. Without a yardr the test
says so and goes on.

`render.jq` follows `call.go` as read, not a capture from a running
yard, so its fidelity is the reading's. The first live run is the check
on it.

## Left out

Downloading full file contents (the next bead; search hits may still
include Graph's short text excerpts); any write; Outlook and Teams;
reading as each person (yardr's B11 is not built); delta sync; paging;
private OneDrive content in search; the national clouds, whose login
and Graph hosts differ; a certificate or federated credential in place
of the client secret.
Each is a later bead, on mckean's word.
# Setting up Microsoft

The pack wires SharePoint and OneDrive into the yard as the declared
source `microsoft`. An agent lists a tenant's sites, a site's document
libraries, and the folders and files of a library, reads one item's
metadata and its link, and searches documents by words. It only reads,
and it does not download full file bytes. Search hits may include short
text excerpts from matching documents.

Read this before the steps. The yard reads as one Microsoft Entra app,
an identity of its own with no person signed in. Whatever that app may
read, every agent the operator grants this source reads, and the yard
does not narrow it per person. Where different people may see different
documents, this source does not fit: a folder per group of people,
synced by IT, is the safer route. Choose the app's permission with that
in mind.

The steps are for whoever runs Microsoft 365 for the team. They follow
Microsoft's Graph and Entra documentation as of 2 November 2026, and
none was run against a tenant when the pack was written.

Which application permission? Each is added in step 3, and each gives
the app what Microsoft's own words say:

- **Sites.Selected.** "Allow the application to access a subset of site
  collections without a signed in user. The specific site collections
  and the permissions granted will be configured in SharePoint Online."
  It reads nothing until step 4 grants it a site, and then only that
  site. Microsoft says site search does not support it, and its Search
  API page does not list it. So with it `drives`, `root`, `children` and
  `item` should work (as the documentation reads, not as tried) and
  `sites` and `search` will not. This is the narrow choice, and the one
  to prefer.
- **Files.Read.All.** "Allows the app to read all files in all site
  collections without a signed in user." Every file in the tenant. It
  adds `search` to the four above.
- **Sites.Read.All.** "Allows the app to read documents and list items
  in all site collections without a signed in user." Every document and
  list item in the tenant. It adds `sites` to what Files.Read.All gives.

If the team wants search, one of the last two is the narrowest Microsoft
lists for it, and the app then reads the whole tenant.

1. **Register the app.** In the Microsoft Entra admin center
   (https://entra.microsoft.com), signed in as at least an Application
   Developer: Entra ID, App registrations, New registration. Name it for
   what it does, such as "yardr documents". Under Supported account
   types choose "Single tenant only". Register. On the app's Overview
   page copy the Application (client) ID and the Directory (tenant) ID.
2. **Make a client secret.** Certificates & secrets, Client secrets, New
   client secret. Microsoft limits a secret to 24 months. Copy the
   Value at once, since the page never shows it again, and not the
   Secret ID beside it. Microsoft's own page says a client secret
   "should not be used" in production and prefers a certificate or a
   federated credential. This pack takes a secret, and a certificate
   mint would be a later bead. Put the expiry date in a calendar,
   because on that day every call fails with AADSTS7000222 until step 7
   is done again with a new secret.
3. **Give the app its permission.** API permissions, Add a permission,
   Microsoft Graph, Application permissions (not Delegated), then the
   one chosen above. Add permissions, then "Grant admin consent for" the
   tenant, which takes an administrator who may consent to application
   permissions. A delegated permission such as User.Read on the list is
   not used and can go.
4. **Sites.Selected only: grant the app each site.** The permission
   reads nothing until a site is granted it. Per Microsoft, the caller
   needs the permission Sites.FullControl.All, either as an application
   or as a delegated user who holds a SharePoint administrator role or
   higher and is a member of the site. First the site's id, from its
   address (`contoso.sharepoint.com` and `/sites/Marketing` here). The
   `id` in the answer, `contoso.sharepoint.com,<guid>,<guid>`, is what
   the yard is given later:
   ```http
   GET https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/Marketing
   ```
   Then the grant, with the role `read` and no other (the roles Microsoft
   lists are read, write, owner and fullcontrol), and the Application
   (client) ID of step 1:
   ```http
   POST https://graph.microsoft.com/v1.0/sites/{site id}/permissions
   Content-Type: application/json

   {"roles": ["read"], "grantedToIdentities": [{"application": {"id": "<Application (client) ID>", "displayName": "yardr documents"}}]}
   ```
   `GET` on the same address lists what a site has granted, and `DELETE`
   on `/permissions/{permission id}` takes a grant back.
5. **Search only: the region.** App-only search must say where the
   tenant's data lives, and the call takes it as `region`: the
   PreferredDataLocation code of Microsoft's Multi-Geo page, three
   capitals. `NAM` is the United States, `GBR` the United Kingdom, `CAN`
   Canada, `AUS` Australia, `IND` India and `JPN` Japan. `EUR` covers a
   group of European countries and `APC` a group of Asia-Pacific ones.
   The page lists the rest
   (https://learn.microsoft.com/microsoft-365/enterprise/microsoft-365-multi-geo).
   Graph answers the code as `dataLocationCode` for a Multi-Geo tenant
   and for no other. Tell the agents the code, in the yardmaster's
   instructions or the grant's context, since the declaration cannot
   hold it. Search finds shared content only. A file only its owner's
   OneDrive holds privately is not found.
6. **Apply the pack.** As the operator, from main of a checkout of the
   yardr-packs repository:
   ```sh
   yardr pack apply <checkout>/microsoft
   ```
7. **Give the yard the three values.** The tenant (the Directory ID or
   a domain such as `contoso.onmicrosoft.com`), the Application ID and
   the secret's Value. The command asks for each and shows nothing as
   it is typed:
   ```sh
   yardr source key microsoft
   ```
   A crew member who needs them asks on a card instead, and the
   operator answers it at a terminal:
   ```sh
   yardr bead ask <bead> "Give the yard its Microsoft app?" --secret microsoft --context "The tenant ID, the app's client ID and a client secret's Value, from the app registration in the Entra admin center."
   ```
   The values reach `microsoft-mint`, the program the pack brings, on
   its standard input and nowhere else. It sends them to
   login.microsoftonline.com, through the proxy and company CA
   variables the yard hands on, and answers a token good for about an
   hour that the yard keeps in memory alone. When the secret is
   replaced, give the new Value alone:
   ```sh
   yardr source key microsoft MICROSOFT_CLIENT_SECRET
   ```
8. **The grant.** A pack of kind source asks none at import, so what a
   crew member may call is granted afterwards. The pack's one preset,
   as `yardr pack show microsoft` prints it:
   1. `read`: every call, which lists, searches and reads and changes
      nothing, and it covers any read call the source is later given.
   The app is one identity, so a grant of `read` lets that crew member
   or group read everything the app may read. Grant it to those who need
   it:
   ```sh
   yardr source grant microsoft read --crew yardmaster
   ```
   A grantee who comes later gets one card:
   ```sh
   yardr bead ask <bead> "Let the reviewers read Microsoft documents" --run "source grant microsoft read --group reviewers" --context "The reviewers read the team's documents to check a bead's work; the card names each call it covers."
   ```
   One caller may make 60 calls of a source in an hour, and an agent
   that walks a library folder by folder uses them quickly. The
   operator raises the limit where the work needs it:
   ```sh
   yardr source set microsoft calls_per_hour=300
   ```
9. **Try it.** The file names no check call, because which call works
   depends on the permission (`sites` and `search` fail under
   Sites.Selected), so `yardr source check microsoft` reports where the
   three values are set and sends nothing. The first call is the test.
   With Sites.Read.All:
   ```sh
   yardr call microsoft sites query=Marketing
   ```
   With Sites.Selected, from step 4's site id:
   ```sh
   yardr call microsoft drives site=contoso.sharepoint.com,<guid>,<guid>
   ```
   A call that stops before it reaches Graph is the mint, and its error
   names the code Entra gave. AADSTS7000215 is an invalid client secret:
   check that the Value was typed and not the Secret ID. AADSTS7000222
   is an expired secret. AADSTS700016 is an Application ID the tenant
   does not know, and AADSTS90002 a tenant it cannot find. A 401 or 403
   from Graph itself points at the app's permission, the admin consent
   or, under Sites.Selected, the grant on that site.
10. **What an agent does with it.** Ids come from the call before. From
    a site's id to its libraries, from a library's id to its top level,
    from a folder's id to what is in it, and from an item's id to its
    link:
    ```sh
    yardr call microsoft drives site=<site id>
    ```
    ```sh
    yardr call microsoft root drive=<drive id>
    ```
    ```sh
    yardr call microsoft children drive=<drive id> item=<folder id>
    ```
    ```sh
    yardr call microsoft item drive=<drive id> item=<item id>
    ```
    `webUrl` in the answer is the link a person opens. Search answers hits
    with the same `webUrl` and a `parentReference` that carries the
    `driveId` and `siteId`; Graph may also return a short matching text
    excerpt in each hit's `summary`. `size` and `from` page through them:
    ```sh
    yardr call microsoft search query="budget 2026" region=NAM
    ```
    `root` and `children` answer one page of 50 items, and `top` asks for
    another size. An answer that has more keeps `@odata.nextLink` and
    follows none.

What is not there, on purpose: downloading full file contents (no call
here downloads a file's bytes, and every driveItem request leaves out
the download link Graph would otherwise add; search results may include
short text excerpts); any write; Outlook, Teams, mail and calendar;
reading as each person, with their own sign-in, which the yard does not
build yet; delta sync; paging past the first page; private OneDrive
content in search; and the national clouds, since the pack speaks to
login.microsoftonline.com and graph.microsoft.com alone. The yard's
source format cannot express what is not listed, and the pack does not
change yardr.
#!/bin/sh
# The microsoft pack's tests: what the gate runs beside yardr pack check.
# Needs no network and no secret, done in seconds. It wants jq 1.6 or
# later, and one of ruby, python3 with PyYAML or yq to read microsoft.yaml
# as data; with none of them it fails and says so, since a skipped test
# would leave the requests unpinned.
#
# The calls. microsoft.yaml is read as data and testdata/render.jq builds
# from it, for each fixture in testdata/calls/, the request yardr sends
# for the fixture's parameters and what the call keeps of the fixture's
# recorded Graph answer. Both are compared with what the fixture says they
# must be, which was written from Microsoft's documentation and not from
# the declaration, so a changed path, query, body, parameter pattern or
# select fails here. The fixture's refusals are parameters the call must
# turn away before sending. The declaration's call names and kinds are
# also compared with what yardr pack check reads of the same file.
#
# The mint. Each case in testdata/mint/cases.json runs microsoft-mint
# itself, as the core does: stdin of {inputs, scope}, a bare environment,
# a PATH that holds only a stand-in curl (it records the arguments, stdin
# and environment it was given and answers from the case), a jq that logs
# its arguments before running the real one, and cat. Of each run the test
# compares the answer, the exit status and the message; for the cases that
# say so, the curl arguments and the form against testdata/mint/request.*;
# and for all of them that the secret is in no output and in no argument
# of curl or jq, that no file was written, and that the script gave curl
# no option that would override a proxy or CA variable.
#
# The grant. The commands SETUP.md teaches for granting the source (step
# 8's, and the --run of the card for a later grantee) are read out of
# SETUP.md and run as written against the yardr on PATH, in a scratch
# yard with the pack applied from its directory, as step 6 says. Each
# must be accepted: yardr takes --preset only for a pack inside its own
# binary, so a SETUP that teaches it fails here and yardr says why.
# Without yardr the grant is not tried, and the test says so.
set -u
pack=$(cd "$(dirname "$0")" && pwd)
cd "$pack" || exit 1
fail=0

command -v jq >/dev/null 2>&1 || { echo "test: needs jq on PATH"; exit 1; }
if [ "$(jq -nr '"é &" | @uri')" != "%C3%A9%20%26" ] || [ "$(jq -nr '"ab" | gsub("(?<n>b)"; "[\(.n)]")')" != "a[b]" ]; then
  echo "test: needs jq 1.6 or later ($(jq --version))"
  exit 1
fi

tmp=$(mktemp -d "${TMPDIR:-/tmp}/microsoft-test.XXXXXX") || exit 1
# The grant's scratch yard (below) is made when its turn comes, and has a
# server that its first command starts and that would outlive the
# directory it owns: stop it first. core is empty until then, whatever the
# environment holds.
core=
# The trap runs this, which shellcheck does not see in a script that ends
# in exit.
# shellcheck disable=SC2329
cleanup() {
  if [ -n "${core:-}" ]; then
    yrun server stop >/dev/null 2>&1
    rm -rf "${core:?}"
  fi
  rm -rf "${tmp:?}"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

# yaml2json <file> writes the file, read as YAML, as JSON on stdout.
yaml2json() {
  if command -v ruby >/dev/null 2>&1; then
    ruby -ryaml -rjson -e 'puts JSON.generate(YAML.safe_load(File.read(ARGV[0])))' "$1"
  elif command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null; then
    python3 -c 'import sys, json, yaml; print(json.dumps(yaml.safe_load(open(sys.argv[1]))))' "$1"
  elif command -v yq >/dev/null 2>&1; then
    yq -o=json '.' "$1" 2>/dev/null || yq '.' "$1"
  else
    echo "needs ruby, python3 with PyYAML, or yq to read $1" >&2
    return 1
  fi
}

# same <what> <wanted.json> <got.json> is 0 when both are the same JSON,
# keys in any order, and says the difference where they are not.
same() {
  jq -S . "$2" > "$tmp/want.txt" 2>/dev/null || { echo "test: $1: $2 is no JSON"; return 1; }
  jq -S . "$3" > "$tmp/got.txt" 2>/dev/null || { echo "test: $1: $3 is no JSON"; return 1; }
  if cmp -s "$tmp/want.txt" "$tmp/got.txt"; then return 0; fi
  echo "test: $1 differs (- wanted, + got):"
  diff -u "$tmp/want.txt" "$tmp/got.txt" | sed 's/^/  /'
  return 1
}

# ---- the declaration ----------------------------------------------------

if ! yaml2json microsoft.yaml > "$tmp/decl.json" 2> "$tmp/yaml.err"; then
  echo "test: microsoft.yaml cannot be read as data: $(cat "$tmp/yaml.err")"
  fail=1
else
  jq '{name, base, check: (.check // null), credential, kinds: (.calls | map_values(.kind))}' "$tmp/decl.json" > "$tmp/got.json"
  if same "declaration (name, base, credential, kinds)" testdata/declaration.json "$tmp/got.json"; then
    echo "test: declaration ok"
  else
    fail=1
  fi

  # Every call has a fixture, and every fixture a call.
  jq -r '.calls | keys[]' "$tmp/decl.json" | LC_ALL=C sort > "$tmp/declared.txt"
  jq -r '.call' testdata/calls/*.json | LC_ALL=C sort -u > "$tmp/fixtured.txt"
  if cmp -s "$tmp/declared.txt" "$tmp/fixtured.txt"; then
    echo "test: every call has a fixture"
  else
    echo "test: the calls of microsoft.yaml and of testdata/calls/ differ (- declared, + fixtured):"
    diff -u "$tmp/declared.txt" "$tmp/fixtured.txt" | sed 's/^/  /'
    fail=1
  fi

  for f in testdata/calls/*.json; do
    [ -e "$f" ] || continue
    label=$(basename "$f" .json)
    call=$(jq -r .call "$f")
    bad=0
    jq -n --slurpfile d "$tmp/decl.json" --slurpfile f "$f" --arg call "$call" \
      '{decl: $d[0], call: $call, given: $f[0].params, answer: $f[0].answer}' |
      jq -f testdata/render.jq > "$tmp/got.json"
    jq '.want' "$f" > "$tmp/want.json"
    same "call $label (request and kept answer)" "$tmp/want.json" "$tmp/got.json" || bad=1
    nref=$(jq '.refused // [] | length' "$f")
    r=0
    while [ "$r" -lt "$nref" ]; do
      jq -n --slurpfile d "$tmp/decl.json" --slurpfile f "$f" --arg call "$call" --argjson r "$r" \
        '{decl: $d[0], call: $call, given: $f[0].refused[$r].params, answer: {}}' |
        jq -f testdata/render.jq > "$tmp/got.json"
      jq --argjson r "$r" '{error: .refused[$r].error}' "$f" > "$tmp/want.json"
      same "call $label refusal $r" "$tmp/want.json" "$tmp/got.json" || bad=1
      r=$((r + 1))
    done
    if [ "$bad" = 0 ]; then echo "test: call $label ok ($nref refusals)"; else fail=1; fi
  done
fi

# The same file as yardr itself reads it: the source, its base, its calls
# with their kinds and its mint command. As .yardr/test finds yardr.
YARDR=${YARDR:-$(command -v yardr || echo /Users/mckean/go/bin/yardr)}
if [ -x "$YARDR" ] && [ -s "$tmp/decl.json" ]; then
  "$YARDR" pack check . --json 2>/dev/null | jq -r '.declared[0] | (.source, .base, (.calls[]), (.commands[].program))' > "$tmp/yardr.txt"
  jq -r '.name, .base, (.calls | to_entries[] | "\(.key) (\(.value.kind))"), .credential.mint_command[0]' "$tmp/decl.json" > "$tmp/ours.txt"
  if cmp -s "$tmp/ours.txt" "$tmp/yardr.txt"; then
    echo "test: yardr pack check reads the same calls and mint"
  else
    echo "test: yardr pack check reads microsoft.yaml otherwise (- read here, + read by yardr):"
    diff -u "$tmp/ours.txt" "$tmp/yardr.txt" | sed 's/^/  /'
    fail=1
  fi
else
  echo "test: yardr not found: the calls were not compared with what it reads"
fi

# ---- the mint -------------------------------------------------------------

real_jq=$(command -v jq)
nl='
'

# The PATH of every run: the stand-ins and cat, and nothing else.
mkdir -p "$tmp/bin"
ln -s "$pack/testdata/bin/curl" "$tmp/bin/curl"
ln -s "$pack/testdata/bin/jq" "$tmp/bin/jq"
ln -s "$(command -v cat)" "$tmp/bin/cat"

# What to look for of the golden secret, one line each: as it is,
# percent-encoded, as the whole of stdin carries it (Go writes an
# ampersand there as a backslash-u escape), and its longest run of letters
# and digits, which none of those writes differently. The golden secret
# has one, and a case that changes the secret changes it to none worth
# looking for.
secret=$(jq -r '.inputs.MICROSOFT_CLIENT_SECRET' testdata/mint/stdin.json)
{
  printf '%s\n' "$secret"
  printf '%s' "$secret" | jq -Rr '@uri'
  cat testdata/mint/stdin.json
  echo
  printf '%s\n' "$secret" | grep -o '[A-Za-z0-9]*' | awk 'length($0) > m { m = length($0); s = $0 } END { if (m >= 6) print s }'
} | sed '/^$/d' > "$tmp/needles"

# mint_case <index> runs one case of cases.json, says what is wrong with
# it, and is 0 when nothing is. The case's fields reach the shell as
# assignments from one jq run, which shellcheck cannot see.
# shellcheck disable=SC2154
mint_case() {
  idx=$1
  c="$tmp/c$idx.json"
  jq -c ".[$idx]" testdata/mint/cases.json > "$c"
  eval "$(jq -r '@sh "name=\(.name) has_stdin=\(has("stdin")) has_edit=\(has("set") or has("drop")) has_body=\(has("body")) body_text=\(.body | type == "string") has_env=\(has("env")) status=\(.status // 200) cexit=\(.curl_exit // 0) want_exit=\(.want.exit) want_curl=\(.want.curl) want_request=\(.want.request // false) want_token=\(.want.token // "") want_err=\(.want.stderr // "") exp_in=\(((.body | objects | .expires_in) // 0) | tonumber)"' "$c")"
  w="$tmp/m$idx"
  mkdir -p "$w/home" "$w/work" "$w/cap"
  problems=""
  say() { problems="$problems
    $*"; }

  # What the core writes on stdin: the golden file as it is, with the
  # case's changes to its inputs, or the case's own text.
  stdin_file="$w/stdin"
  if [ "$has_stdin" = true ]; then
    jq -j .stdin "$c" > "$stdin_file"
  elif [ "$has_edit" = true ]; then
    jq -cj --slurpfile c "$c" '.inputs |= ((. + ($c[0].set // {})) | with_entries(select(.key as $k | (($c[0].drop // []) | index($k)) == null)))' testdata/mint/stdin.json > "$stdin_file"
  else
    stdin_file="$pack/testdata/mint/stdin.json"
  fi
  # What the stand-in curl answers.
  if [ "$has_body" != true ]; then
    : > "$w/body"
  elif [ "$body_text" = true ]; then
    jq -j .body "$c" > "$w/body"
  else
    jq -c .body "$c" > "$w/body"
  fi
  # The environment the core gives a command, and the stand-ins' own.
  set -- "PATH=$tmp/bin" "HOME=$w/home" "USER=u" "LOGNAME=u" \
    "YARDR_SOURCE_BASE=https://graph.microsoft.com/v1.0" \
    "MS_CAPTURE=$w/cap" "MS_REAL_JQ=$real_jq" "MS_CURL_BODY=$w/body" "MS_CURL_STATUS=$status" "MS_CURL_EXIT=$cexit"
  if [ "$has_env" = true ]; then
    jq -r '.env | to_entries[] | "\(.key)=\(.value)"' "$c" > "$w/env.lines"
    while IFS= read -r kv; do set -- "$@" "$kv"; done < "$w/env.lines"
  fi

  t0=0 t1=0
  [ -z "$want_token" ] || t0=$(date +%s)
  ( cd "$w/work" && env -i "$@" "$pack/microsoft-mint" < "$stdin_file" > "$w/out" 2> "$w/err" )
  rc=$?
  [ -z "$want_token" ] || t1=$(date +%s)

  # The answer, the status and the message.
  [ "$rc" = "$want_exit" ] || say "exit $rc, wanted $want_exit"
  if [ -n "$want_token" ]; then
    got=$(jq -r 'if keys == ["expires_at", "token"] and (.expires_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) then "\(.token)\n\(.expires_at | fromdate)" else empty end' "$w/out" 2>/dev/null)
    if [ -z "$got" ]; then
      say "stdout is not one {token, expires_at} with an RFC 3339 time in Z: $(cat "$w/out")"
    else
      [ "${got%%"$nl"*}" = "$want_token" ] || say "the token is not the one wanted"
      exp_at=${got#*"$nl"}
      if [ "$exp_at" -lt $((t0 + exp_in - 2)) ] || [ "$exp_at" -gt $((t1 + exp_in + 2)) ]; then
        say "expires_at is not expires_in ($exp_in s) from now"
      fi
    fi
  else
    [ ! -s "$w/out" ] || say "stdout should be empty: $(cat "$w/out")"
  fi
  got_err=$(cat "$w/err")
  [ "$got_err" = "$want_err" ] || say "stderr is '$got_err', wanted '$want_err'"

  # What curl was asked for.
  if [ "$want_curl" = true ]; then
    [ -f "$w/cap/curl.argv" ] || say "curl was not run"
  elif [ -f "$w/cap/curl.argv" ]; then
    say "curl was run, and nothing should have been sent"
  fi
  if [ "$want_request" = true ]; then
    if ! cmp -s testdata/mint/request.argv "$w/cap/curl.argv"; then
      say "curl's arguments differ (- wanted, + got):"
      problems="$problems
$(diff -u testdata/mint/request.argv "$w/cap/curl.argv" | sed 's/^/      /')"
    fi
    cmp -s testdata/mint/request.stdin "$w/cap/curl.stdin" || say "the form handed to curl differs from testdata/mint/request.stdin"
  fi
  if [ -f "$w/cap/curl.env" ]; then
    if [ "$has_env" = true ]; then
      jq -r '(.want.env // [])[]' "$c" | LC_ALL=C sort > "$w/want.env"
      cmp -s "$w/want.env" "$w/cap/curl.env" || say "curl's proxy and CA variables are not the ones set: $(cat "$w/cap/curl.env")"
    elif [ -s "$w/cap/curl.env" ]; then
      say "curl was given proxy or CA variables that were not set: $(cat "$w/cap/curl.env")"
    fi
    if grep -E -x -q -e '--proxy.*|--noproxy|--cacert|--capath|--cert.*|--insecure|-k|-x|-E|--ssl-no-revoke' "$w/cap/curl.argv"; then
      say "curl was given an option that overrides a proxy or CA variable"
    fi
  fi

  # The secret, in no output and in no argument of curl or jq. grep reads
  # bytes, which is what a leak is made of.
  if LC_ALL=C grep -F -q -f "$tmp/needles" "$w/out" "$w/err" "$w/cap/curl.argv" "$w/cap/jq.argv" 2>/dev/null; then
    for f in out err cap/curl.argv cap/jq.argv; do
      if LC_ALL=C grep -F -q -f "$tmp/needles" "$w/$f" 2>/dev/null; then say "the secret is in $f"; fi
    done
  fi
  if [ -n "$(find "$w/work" "$w/home" -type f 2>/dev/null)" ]; then say "the script wrote a file"; fi

  if [ -n "$problems" ]; then
    echo "test: mint case $name:$problems"
    return 1
  fi
  return 0
}

ncases=$(jq 'length' testdata/mint/cases.json)
mint_bad=0
i=0
while [ "$i" -lt "$ncases" ]; do
  mint_case "$i" || mint_bad=$((mint_bad + 1))
  i=$((i + 1))
done
if [ "$mint_bad" = 0 ]; then
  echo "test: mint ok ($ncases cases)"
else
  echo "test: mint failed in $mint_bad of $ncases cases"
  fail=1
fi

# ---- the grant ------------------------------------------------------------

# The commands are read out of SETUP.md, not copied here: a copy would pass
# whatever SETUP teaches, and SETUP once taught --preset, which yardr
# refuses for a pack applied from a directory. The scratch yard is under a
# short path (a unix socket's path is held to 100 bytes), and each command
# of it runs with an environment of its own home and a system PATH, so none
# of this session's YARDR_* variables lead back to a real yard. It starts
# no agent, and its server listens on a unix socket alone. The card of
# SETUP.md grants the group reviewers, which a fresh yard has none of.
grep -o 'source grant [^`"]*' SETUP.md > "$tmp/grants"
if [ ! -s "$tmp/grants" ]; then
  echo "test: grant failed: SETUP.md teaches no source grant command"
  fail=1
elif [ ! -x "$YARDR" ]; then
  echo "test: yardr not found: the grants of SETUP.md were not tried"
else
  yrun() { env -i HOME="$core/h" PATH=/usr/bin:/bin "$YARDR" --home "$core/y" --actor tester "$@"; }
  core=$(mktemp -d /tmp/microsoftt.XXXXXX) || exit 1
  mkdir -p "$core/h"
  if ! yrun init --harness claude --operator tester > "$core/out" 2>&1; then
    echo "test: grant failed: the scratch yard could not be made: $(tail -c 300 "$core/out")"
    fail=1
  elif ! yrun pack apply "$pack" --yes > "$core/out" 2>&1; then
    echo "test: grant failed: the pack did not apply from its directory: $(tail -c 300 "$core/out")"
    fail=1
  elif ! yrun group add reviewers --runner manual > "$core/out" 2>&1; then
    echo "test: grant failed: the group of the card could not be made: $(tail -c 300 "$core/out")"
    fail=1
  else
    while IFS= read -r cmd; do
      # A grant is plain words, so $cmd is split on purpose. Its stdin is
      # /dev/null so that it cannot read the rest of the list.
      # shellcheck disable=SC2086
      if yrun $cmd < /dev/null > "$core/out" 2>&1; then
        echo "test: grant ok (yardr $cmd)"
      else
        echo "test: grant failed: yardr refused 'yardr $cmd', which SETUP.md teaches: $(tail -c 300 "$core/out")"
        fail=1
      fi
    done < "$tmp/grants"
  fi
fi

exit $fail
#!/bin/sh
# A stand-in for curl, first on the PATH of the mint's test, so that no
# request leaves the machine and the test can read exactly what the mint
# asked curl for. It writes into MS_CAPTURE: curl.argv (one argument a
# line), curl.stdin (the bytes it was handed) and curl.env (the proxy and
# CA variables it was started with, sorted). It answers from the files
# the test names:
#   MS_CURL_BODY    the body it answers (none if unset)
#   MS_CURL_STATUS  the status it answers, 200 if unset
#   MS_CURL_EXIT    a curl exit status to fail with, answering nothing
# Of curl's options it understands one, --write-out, and of its formats
# the one the mint uses: \n and %{http_code}.
PATH=/usr/bin:/bin
: "${MS_CAPTURE:?}"
printf '%s\n' "$@" > "$MS_CAPTURE/curl.argv"
cat > "$MS_CAPTURE/curl.stdin"
env | grep -E '^(HTTPS?_PROXY|https?_proxy|NO_PROXY|no_proxy|SSL_CERT_FILE|SSL_CERT_DIR|NODE_EXTRA_CA_CERTS)=' | LC_ALL=C sort > "$MS_CAPTURE/curl.env"
[ "${MS_CURL_EXIT:-0}" = 0 ] || exit "$MS_CURL_EXIT"
[ -z "${MS_CURL_BODY:-}" ] || cat "$MS_CURL_BODY"
fmt=''
prev=''
for a in "$@"; do
  case $prev in --write-out|-w) fmt=$a ;; esac
  prev=$a
done
if [ -n "$fmt" ]; then
  printf '%s' "$fmt" | sed -e 's/\\n/\
/g' -e "s/%{http_code}/${MS_CURL_STATUS:-200}/g"
fi
#!/bin/sh
# A stand-in for jq, on the PATH of the mint's test beside the stand-in
# curl: it logs the arguments of each run to MS_CAPTURE/jq.argv, then
# runs the real jq (MS_REAL_JQ) on them. The test reads the log for a
# secret in an argument, where `ps` would show it to anyone on the box.
: "${MS_CAPTURE:?}" "${MS_REAL_JQ:?}"
printf '%s\n' "$*" >> "$MS_CAPTURE/jq.argv"
exec "$MS_REAL_JQ" "$@"
{
  "call": "children",
  "about": "A folder's items, with top given. A page that ends with the last items has no next link.",
  "params": { "drive": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd", "item": "01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K", "top": "10" },
  "answer": {
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#drives('b%21-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd')/items('01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K')/children(id,name,webUrl,size,lastModifiedDateTime,file,folder,parentReference)",
    "value": [
      {
        "id": "01BYE5RZ7CQJ6U3A6ZXRGZSPXVUT3EMLKH",
        "name": "Leave policy.docx",
        "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Policies/Leave%20policy.docx",
        "size": 40960,
        "lastModifiedDateTime": "2026-09-30T08:00:00Z",
        "file": { "mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
        "parentReference": {
          "driveType": "documentLibrary",
          "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
          "id": "01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K",
          "name": "Policies",
          "path": "/drives/b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root:/Policies",
          "siteId": "da60e844-ba1d-49bc-b4d4-d5e36bae9019"
        }
      }
    ]
  },
  "want": {
    "request": {
      "method": "GET",
      "url": "https://graph.microsoft.com/v1.0/drives/b%21-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/items/01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K/children?%24select=id%2Cname%2CwebUrl%2Csize%2ClastModifiedDateTime%2Cfile%2Cfolder%2CparentReference&%24top=10",
      "headers": { "Accept": "application/json", "Authorization": "Bearer <token>", "User-Agent": "yardr" }
    },
    "kept": {
      "value": [
        {
          "id": "01BYE5RZ7CQJ6U3A6ZXRGZSPXVUT3EMLKH",
          "name": "Leave policy.docx",
          "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Policies/Leave%20policy.docx",
          "size": 40960,
          "lastModifiedDateTime": "2026-09-30T08:00:00Z",
          "file": { "mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
          "parentReference": {
            "driveType": "documentLibrary",
            "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
            "id": "01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K",
            "name": "Policies",
            "path": "/drives/b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root:/Policies",
            "siteId": "da60e844-ba1d-49bc-b4d4-d5e36bae9019"
          }
        }
      ]
    },
    "bound": 262144
  },
  "refused": [
    { "params": { "drive": "b!abc" }, "error": "parameter item is missing" },
    { "params": { "drive": "b!abc", "item": "01BY/../x" }, "error": "parameter item does not match its pattern ^[A-Za-z0-9!_-]+$" },
    { "params": { "drive": "b!abc?x=1", "item": "01BY" }, "error": "parameter drive does not match its pattern ^[A-Za-z0-9!_-]+$" }
  ]
}
{
  "call": "drives",
  "about": "A site's libraries. The site id has two commas, which yardr writes as %2C in the path. The answer has the five fields $select asks for, in the shape of Microsoft's example for GET /sites/{siteId}/drives.",
  "params": { "site": "contoso.sharepoint.com,da60e844-ba1d-49bc-b4d4-d5e36bae9019,712a596e-90a1-49e3-9b48-bfa80bee8740" },
  "answer": {
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#drives(id,name,webUrl,driveType,description)",
    "value": [
      {
        "id": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
        "name": "Documents",
        "description": "",
        "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents",
        "driveType": "documentLibrary"
      }
    ]
  },
  "want": {
    "request": {
      "method": "GET",
      "url": "https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com%2Cda60e844-ba1d-49bc-b4d4-d5e36bae9019%2C712a596e-90a1-49e3-9b48-bfa80bee8740/drives?%24select=id%2Cname%2CwebUrl%2CdriveType%2Cdescription",
      "headers": { "Accept": "application/json", "Authorization": "Bearer <token>", "User-Agent": "yardr" }
    },
    "kept": {
      "value": [
        {
          "id": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
          "name": "Documents",
          "description": "",
          "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents",
          "driveType": "documentLibrary"
        }
      ]
    },
    "bound": 262144
  },
  "refused": [
    { "params": {}, "error": "parameter site is missing" },
    { "params": { "site": "../admin" }, "error": "parameter site does not match its pattern ^[A-Za-z0-9][A-Za-z0-9._,-]*$" },
    { "params": { "site": ".." }, "error": "parameter site does not match its pattern ^[A-Za-z0-9][A-Za-z0-9._,-]*$" },
    { "params": { "site": "a/b" }, "error": "parameter site does not match its pattern ^[A-Za-z0-9][A-Za-z0-9._,-]*$" }
  ]
}
{
  "call": "item",
  "about": "One item's metadata and link. The recorded answer is the driveItem of Microsoft's example for GET /drives/{drive-id}/items/{item-id} with the fields Graph adds when it ignores $select, among them @microsoft.graph.downloadUrl, a link that needs no sign-in. select must drop it.",
  "params": { "drive": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd", "item": "01BYE5RZ4DMGLBB4GZPRFYKBQ5A6SVYSKX" },
  "answer": {
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#drives('b%21-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd')/items/$entity",
    "@microsoft.graph.downloadUrl": "https://contoso.sharepoint.com/sites/siteA/_layouts/15/download.aspx?UniqueId=e320afeb-ad73-46a2-83d7-985faa4b206d&Translate=false&tempauth=eyJ0eXAiOiJKV1QifQ.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTBmZjEtY2UwMC0wMDAwMDAwMDAwMDAvY29udG9zby5zaGFyZXBvaW50LmNvbUA3NGM2ZmY2ZC0w.c2ln&ApiVersion=2.0",
    "createdBy": { "user": { "id": "efee1b77-fb3b-4f65-99d6-274c11914d12", "displayName": "Ryan Gregg" } },
    "createdDateTime": "2016-03-21T20:01:37Z",
    "cTag": "\"c:{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},0\"",
    "eTag": "\"{86EB4C8E-D20D-46B9-AD41-23B8868DDA8A},1\"",
    "id": "01BYE5RZ4DMGLBB4GZPRFYKBQ5A6SVYSKX",
    "lastModifiedBy": { "user": { "id": "efee1b77-fb3b-4f65-99d6-274c11914d12", "displayName": "Ryan Gregg" } },
    "lastModifiedDateTime": "2026-10-21T14:02:11Z",
    "name": "Budget 2026.xlsx",
    "size": 197,
    "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Budget%202026.xlsx",
    "file": { "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "hashes": { "quickXorHash": "dGhpcyBpcyBhIHRlc3Q=" } },
    "parentReference": {
      "driveType": "documentLibrary",
      "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
      "id": "01BYE5RZ56Y2GOVW7725BZO354PWSELRRZ",
      "name": "Shared Documents",
      "path": "/drives/b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root:",
      "siteId": "da60e844-ba1d-49bc-b4d4-d5e36bae9019"
    }
  },
  "want": {
    "request": {
      "method": "GET",
      "url": "https://graph.microsoft.com/v1.0/drives/b%21-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/items/01BYE5RZ4DMGLBB4GZPRFYKBQ5A6SVYSKX?%24select=id%2Cname%2CwebUrl%2Csize%2ClastModifiedDateTime%2Cfile%2Cfolder%2CparentReference",
      "headers": { "Accept": "application/json", "Authorization": "Bearer <token>", "User-Agent": "yardr" }
    },
    "kept": {
      "id": "01BYE5RZ4DMGLBB4GZPRFYKBQ5A6SVYSKX",
      "name": "Budget 2026.xlsx",
      "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Budget%202026.xlsx",
      "size": 197,
      "lastModifiedDateTime": "2026-10-21T14:02:11Z",
      "file": { "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "hashes": { "quickXorHash": "dGhpcyBpcyBhIHRlc3Q=" } },
      "parentReference": {
        "driveType": "documentLibrary",
        "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
        "id": "01BYE5RZ56Y2GOVW7725BZO354PWSELRRZ",
        "name": "Shared Documents",
        "path": "/drives/b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root:",
        "siteId": "da60e844-ba1d-49bc-b4d4-d5e36bae9019"
      }
    },
    "bound": 65536
  },
  "refused": [
    { "params": { "drive": "b!abc" }, "error": "parameter item is missing" },
    { "params": { "drive": "b!abc", "item": "x y" }, "error": "parameter item does not match its pattern ^[A-Za-z0-9!_-]+$" }
  ]
}
{
  "call": "root",
  "about": "The top level of a library, with top left out so its default of 50 goes. The answer has the fields $select asks for, in the shape of Microsoft's example for a folder's children: a folder with a childCount, a file, and the next link Graph adds. The drive id has a ! in it, which yardr writes as %21.",
  "params": { "drive": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd" },
  "answer": {
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#drives('b%21-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd')/root/children(id,name,webUrl,size,lastModifiedDateTime,file,folder,parentReference)",
    "value": [
      {
        "id": "01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K",
        "name": "Policies",
        "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Policies",
        "size": 2048,
        "lastModifiedDateTime": "2026-10-20T09:15:00Z",
        "folder": { "childCount": 4 },
        "parentReference": {
          "driveType": "documentLibrary",
          "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
          "id": "01BYE5RZ56Y2GOVW7725BZO354PWSELRRZ",
          "name": "Shared Documents",
          "path": "/drives/b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root:",
          "siteId": "da60e844-ba1d-49bc-b4d4-d5e36bae9019"
        }
      },
      {
        "id": "01BYE5RZ4DMGLBB4GZPRFYKBQ5A6SVYSKX",
        "name": "Budget 2026.xlsx",
        "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Budget%202026.xlsx",
        "size": 197,
        "lastModifiedDateTime": "2026-10-21T14:02:11Z",
        "file": { "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "hashes": { "quickXorHash": "dGhpcyBpcyBhIHRlc3Q=" } },
        "parentReference": {
          "driveType": "documentLibrary",
          "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
          "id": "01BYE5RZ56Y2GOVW7725BZO354PWSELRRZ",
          "name": "Shared Documents",
          "path": "/drives/b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root:",
          "siteId": "da60e844-ba1d-49bc-b4d4-d5e36bae9019"
        }
      }
    ],
    "@odata.nextLink": "https://graph.microsoft.com/v1.0/drives/b%21-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root/children?%24select=id%2Cname&%24top=50&%24skiptoken=UGFnZWQ9VFJVRQ"
  },
  "want": {
    "request": {
      "method": "GET",
      "url": "https://graph.microsoft.com/v1.0/drives/b%21-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root/children?%24select=id%2Cname%2CwebUrl%2Csize%2ClastModifiedDateTime%2Cfile%2Cfolder%2CparentReference&%24top=50",
      "headers": { "Accept": "application/json", "Authorization": "Bearer <token>", "User-Agent": "yardr" }
    },
    "kept": {
      "value": [
        {
          "id": "01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K",
          "name": "Policies",
          "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Policies",
          "size": 2048,
          "lastModifiedDateTime": "2026-10-20T09:15:00Z",
          "folder": { "childCount": 4 },
          "parentReference": {
            "driveType": "documentLibrary",
            "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
            "id": "01BYE5RZ56Y2GOVW7725BZO354PWSELRRZ",
            "name": "Shared Documents",
            "path": "/drives/b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root:",
            "siteId": "da60e844-ba1d-49bc-b4d4-d5e36bae9019"
          }
        },
        {
          "id": "01BYE5RZ4DMGLBB4GZPRFYKBQ5A6SVYSKX",
          "name": "Budget 2026.xlsx",
          "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Budget%202026.xlsx",
          "size": 197,
          "lastModifiedDateTime": "2026-10-21T14:02:11Z",
          "file": { "mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "hashes": { "quickXorHash": "dGhpcyBpcyBhIHRlc3Q=" } },
          "parentReference": {
            "driveType": "documentLibrary",
            "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
            "id": "01BYE5RZ56Y2GOVW7725BZO354PWSELRRZ",
            "name": "Shared Documents",
            "path": "/drives/b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root:",
            "siteId": "da60e844-ba1d-49bc-b4d4-d5e36bae9019"
          }
        }
      ],
      "@odata.nextLink": "https://graph.microsoft.com/v1.0/drives/b%21-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd/root/children?%24select=id%2Cname&%24top=50&%24skiptoken=UGFnZWQ9VFJVRQ"
    },
    "bound": 262144
  },
  "refused": [
    { "params": {}, "error": "parameter drive is missing" },
    { "params": { "drive": "a/b" }, "error": "parameter drive does not match its pattern ^[A-Za-z0-9!_-]+$" },
    { "params": { "drive": "abc", "top": "many" }, "error": "parameter top is no whole number" }
  ]
}
{
  "call": "search",
  "about": "Document search with only the required parameters: size goes as 10 and from as 0. A search with no hit answers a container with a total of 0 and no hits.",
  "params": { "query": "contoso", "region": "EUR" },
  "answer": {
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#search",
    "value": [ { "searchTerms": [ "contoso" ], "hitsContainers": [ { "total": 0, "moreResultsAvailable": false } ] } ]
  },
  "want": {
    "request": {
      "method": "POST",
      "url": "https://graph.microsoft.com/v1.0/search/query",
      "headers": { "Accept": "application/json", "Authorization": "Bearer <token>", "Content-Type": "application/json", "User-Agent": "yardr" },
      "body": {
        "requests": [
          { "entityTypes": [ "driveItem" ], "query": { "queryString": "contoso" }, "region": "EUR", "from": 0, "size": 10 }
        ]
      }
    },
    "kept": {
      "value": [ { "searchTerms": [ "contoso" ], "hitsContainers": [ { "total": 0, "moreResultsAvailable": false } ] } ]
    },
    "bound": 262144
  }
}
{
  "call": "search",
  "about": "Document search with every parameter given. The body is the request of Microsoft's example for searching files (entityTypes driveItem) with the region application permissions require, and size and from as numbers. The answer is that example's response, with the @odata.context Graph adds.",
  "params": { "query": "budget \"Q3 2026\" filetype:xlsx", "region": "NAM", "size": "25", "from": "50" },
  "answer": {
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#search",
    "value": [
      {
        "searchTerms": [ "budget", "q3 2026", "filetype:xlsx" ],
        "hitsContainers": [
          {
            "total": 1,
            "moreResultsAvailable": false,
            "hits": [
              {
                "hitId": "FlULeN/ui/1GjLx1rUfio5UAAEl",
                "rank": 1,
                "summary": "<c0>Budget</c0> <c0>Q3 2026</c0> <ddd/>",
                "resource": {
                  "@odata.type": "#microsoft.graph.driveItem",
                  "createdDateTime": "2026-07-10T06:37:43Z",
                  "lastModifiedDateTime": "2026-10-21T14:02:11Z",
                  "name": "Budget 2026.xlsx",
                  "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Budget%202026.xlsx",
                  "createdBy": { "user": { "displayName": "Ada Lovelace" } },
                  "lastModifiedBy": { "user": { "displayName": "Richard Mayer" } },
                  "parentReference": {
                    "siteId": "contoso.sharepoint.com,da60e844-ba1d-49bc-b4d4-d5e36bae9019,712a596e-90a1-49e3-9b48-bfa80bee8740",
                    "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
                    "sharepointIds": { "listId": "c61d1892-ca82-4f53-b16f-6bb8a379e2b2", "listItemId": "1027", "listItemUniqueId": "E320AFEB-AD73-46A2-83D7-985FAA4B206D" }
                  },
                  "fileSystemInfo": { "createdDateTime": "2026-07-10T06:37:43Z", "lastModifiedDateTime": "2026-10-21T14:02:11Z" }
                }
              }
            ]
          }
        ]
      }
    ]
  },
  "want": {
    "request": {
      "method": "POST",
      "url": "https://graph.microsoft.com/v1.0/search/query",
      "headers": { "Accept": "application/json", "Authorization": "Bearer <token>", "Content-Type": "application/json", "User-Agent": "yardr" },
      "body": {
        "requests": [
          {
            "entityTypes": [ "driveItem" ],
            "query": { "queryString": "budget \"Q3 2026\" filetype:xlsx" },
            "region": "NAM",
            "from": 50,
            "size": 25
          }
        ]
      }
    },
    "kept": {
      "value": [
        {
          "searchTerms": [ "budget", "q3 2026", "filetype:xlsx" ],
          "hitsContainers": [
            {
              "total": 1,
              "moreResultsAvailable": false,
              "hits": [
                {
                  "hitId": "FlULeN/ui/1GjLx1rUfio5UAAEl",
                  "rank": 1,
                  "summary": "<c0>Budget</c0> <c0>Q3 2026</c0> <ddd/>",
                  "resource": {
                    "@odata.type": "#microsoft.graph.driveItem",
                    "createdDateTime": "2026-07-10T06:37:43Z",
                    "lastModifiedDateTime": "2026-10-21T14:02:11Z",
                    "name": "Budget 2026.xlsx",
                    "webUrl": "https://contoso.sharepoint.com/sites/siteA/Shared%20Documents/Budget%202026.xlsx",
                    "createdBy": { "user": { "displayName": "Ada Lovelace" } },
                    "lastModifiedBy": { "user": { "displayName": "Richard Mayer" } },
                    "parentReference": {
                      "siteId": "contoso.sharepoint.com,da60e844-ba1d-49bc-b4d4-d5e36bae9019,712a596e-90a1-49e3-9b48-bfa80bee8740",
                      "driveId": "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd",
                      "sharepointIds": { "listId": "c61d1892-ca82-4f53-b16f-6bb8a379e2b2", "listItemId": "1027", "listItemUniqueId": "E320AFEB-AD73-46A2-83D7-985FAA4B206D" }
                    },
                    "fileSystemInfo": { "createdDateTime": "2026-07-10T06:37:43Z", "lastModifiedDateTime": "2026-10-21T14:02:11Z" }
                  }
                }
              ]
            }
          ]
        }
      ]
    },
    "bound": 262144
  },
  "refused": [
    { "params": { "query": "budget" }, "error": "parameter region is missing" },
    { "params": { "query": "budget", "region": "nam" }, "error": "parameter region does not match its pattern ^[A-Z]{3}$" },
    { "params": { "query": "budget", "region": "NAM", "size": "ten" }, "error": "parameter size is no whole number" },
    { "params": { "query": "budget", "region": "NAM", "fields": "name" }, "error": "\"fields\" is no parameter of the call (it takes: query, region, size, from)" }
  ]
}
{
  "call": "sites",
  "about": "Site search by name. The answer is Microsoft's example for GET /sites?search={query}, with the @odata.context and next link Graph adds. The query has a space and an ampersand, to pin the query encoding.",
  "params": { "query": "Team A & Co" },
  "answer": {
    "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#sites",
    "value": [
      {
        "id": "contoso.sharepoint.com,da60e844-ba1d-49bc-b4d4-d5e36bae9019,712a596e-90a1-49e3-9b48-bfa80bee8740",
        "name": "Team A Site",
        "description": "",
        "createdDateTime": "2016-10-18T03:05:59Z",
        "lastModifiedDateTime": "2016-10-18T10:40:59Z",
        "webUrl": "https://contoso.sharepoint.com/sites/siteA"
      },
      {
        "id": "contoso.sharepoint.com,da60e844-ba1d-49bc-b4d4-d5e36bae9019,0271110f-634f-4300-a841-3a8a2e851851",
        "name": "Team B Site",
        "description": "",
        "createdDateTime": "2016-10-18T03:05:59Z",
        "lastModifiedDateTime": "2016-10-18T10:40:59Z",
        "webUrl": "https://contoso.sharepoint.com/sites/siteB"
      }
    ],
    "@odata.nextLink": "https://graph.microsoft.com/v1.0/sites?search=Team+A+%26+Co&$skiptoken=UGFnZWQ9VFJVRSZwX0lEPTQy"
  },
  "want": {
    "request": {
      "method": "GET",
      "url": "https://graph.microsoft.com/v1.0/sites?search=Team+A+%26+Co",
      "headers": { "Accept": "application/json", "Authorization": "Bearer <token>", "User-Agent": "yardr" }
    },
    "kept": {
      "value": [
        {
          "id": "contoso.sharepoint.com,da60e844-ba1d-49bc-b4d4-d5e36bae9019,712a596e-90a1-49e3-9b48-bfa80bee8740",
          "name": "Team A Site",
          "description": "",
          "createdDateTime": "2016-10-18T03:05:59Z",
          "lastModifiedDateTime": "2016-10-18T10:40:59Z",
          "webUrl": "https://contoso.sharepoint.com/sites/siteA"
        },
        {
          "id": "contoso.sharepoint.com,da60e844-ba1d-49bc-b4d4-d5e36bae9019,0271110f-634f-4300-a841-3a8a2e851851",
          "name": "Team B Site",
          "description": "",
          "createdDateTime": "2016-10-18T03:05:59Z",
          "lastModifiedDateTime": "2016-10-18T10:40:59Z",
          "webUrl": "https://contoso.sharepoint.com/sites/siteB"
        }
      ],
      "@odata.nextLink": "https://graph.microsoft.com/v1.0/sites?search=Team+A+%26+Co&$skiptoken=UGFnZWQ9VFJVRSZwX0lEPTQy"
    },
    "bound": 262144
  },
  "refused": [
    { "params": {}, "error": "parameter query is missing" },
    { "params": { "query": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, "error": "parameter query is 201 characters, and at most 200" },
    { "params": { "query": "Team", "top": "5" }, "error": "\"top\" is no parameter of the call (it takes: query)" }
  ]
}
{
  "name": "microsoft",
  "base": "https://graph.microsoft.com/v1.0",
  "check": null,
  "credential": {
    "env": "MICROSOFT_TOKEN",
    "header": "Authorization: Bearer {credential}",
    "mint": [
      { "name": "MICROSOFT_TENANT", "secret": false },
      { "name": "MICROSOFT_CLIENT_ID", "secret": false },
      "MICROSOFT_CLIENT_SECRET"
    ],
    "mint_command": [ "microsoft-mint" ]
  },
  "kinds": {
    "sites": "read",
    "drives": "read",
    "root": "read",
    "children": "read",
    "item": "read",
    "search": "read"
  }
}
[
  {
    "name": "good",
    "about": "Microsoft's success answer. The stand-in curl is handed exactly request.argv and request.stdin; the secret in stdin.json has & < > \" \\ + / = % a space and non-ASCII in it, and goes as the form body of request.stdin says.",
    "body": { "token_type": "Bearer", "expires_in": 3599, "ext_expires_in": 3599, "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJodHRwczovL2dyYXBoLm1pY3Jvc29mdC5jb20ifQ.c2lnbmF0dXJl" },
    "want": { "exit": 0, "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJodHRwczovL2dyYXBoLm1pY3Jvc29mdC5jb20ifQ.c2lnbmF0dXJl", "curl": true, "request": true }
  },
  {
    "name": "expires-in-as-a-string",
    "about": "The older endpoint writes expires_in as a string.",
    "body": { "token_type": "Bearer", "expires_in": "3599", "access_token": "abc.def.ghi" },
    "want": { "exit": 0, "token": "abc.def.ghi", "curl": true }
  },
  {
    "name": "proxy-and-ca-variables",
    "about": "The nine variables the core hands a command reach curl as they were set, and the script adds no option of its own that would override them.",
    "env": {
      "HTTPS_PROXY": "http://proxy.corp:3128",
      "https_proxy": "http://proxy.corp:3128",
      "HTTP_PROXY": "http://proxy.corp:3128",
      "http_proxy": "http://proxy.corp:3128",
      "NO_PROXY": "localhost,.corp",
      "no_proxy": "localhost,.corp",
      "SSL_CERT_FILE": "/etc/ssl/corp.pem",
      "SSL_CERT_DIR": "/etc/ssl/corp",
      "NODE_EXTRA_CA_CERTS": "/etc/ssl/corp-extra.pem"
    },
    "body": { "token_type": "Bearer", "expires_in": 3599, "access_token": "abc.def.ghi" },
    "want": {
      "exit": 0,
      "token": "abc.def.ghi",
      "curl": true,
      "request": true,
      "env": [
        "HTTPS_PROXY=http://proxy.corp:3128",
        "https_proxy=http://proxy.corp:3128",
        "HTTP_PROXY=http://proxy.corp:3128",
        "http_proxy=http://proxy.corp:3128",
        "NO_PROXY=localhost,.corp",
        "no_proxy=localhost,.corp",
        "SSL_CERT_FILE=/etc/ssl/corp.pem",
        "SSL_CERT_DIR=/etc/ssl/corp",
        "NODE_EXTRA_CA_CERTS=/etc/ssl/corp-extra.pem"
      ]
    }
  },
  {
    "name": "wrong-secret",
    "about": "Entra's 401, whose description repeats the secret. The failure says the status, the error word and the AADSTS number, and nothing of the description.",
    "status": 401,
    "body": { "error": "invalid_client", "error_description": "AADSTS7000215: Invalid client secret provided. p&ss+SEKRET9f3c+w0rd/=%20 \"q\" \\ ünï <x> ~._-é\r\nTrace ID: 0000aaaa-11bb-cccc-dd22-eeeeee333333", "error_codes": [ 7000215 ], "timestamp": "2026-11-02 09:00:00Z", "trace_id": "0000aaaa-11bb-cccc-dd22-eeeeee333333" },
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 401 (invalid_client, AADSTS7000215)", "curl": true }
  },
  {
    "name": "expired-secret",
    "status": 401,
    "body": { "error": "invalid_client", "error_description": "AADSTS7000222: The provided client secret keys are expired.", "error_codes": [ 7000222 ] },
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 401 (invalid_client, AADSTS7000222)", "curl": true }
  },
  {
    "name": "unknown-app",
    "status": 400,
    "body": { "error": "unauthorized_client", "error_description": "AADSTS700016: Application not found in the directory.", "error_codes": [ 700016 ] },
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 400 (unauthorized_client, AADSTS700016)", "curl": true }
  },
  {
    "name": "unknown-tenant",
    "status": 400,
    "body": { "error": "invalid_request", "error_description": "AADSTS90002: Tenant not found.", "error_codes": [ 90002 ] },
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 400 (invalid_request, AADSTS90002)", "curl": true }
  },
  {
    "name": "gateway-html",
    "about": "A proxy's page, with the secret in it, says only its status.",
    "status": 502,
    "body": "<html><body>Bad gateway p&ss+SEKRET9f3c+w0rd/=%20</body></html>",
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 502", "curl": true }
  },
  {
    "name": "error-word-that-is-not-a-code",
    "about": "An error that is not one short lower-case word is not repeated; the number is.",
    "status": 400,
    "body": { "error": "Bad Word p&ss+SEKRET9f3c+w0rd/=%20", "error_codes": [ 70011 ] },
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 400 (AADSTS70011)", "curl": true }
  },
  {
    "name": "error-with-no-fields",
    "status": 500,
    "body": {},
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 500", "curl": true }
  },
  {
    "name": "no-token",
    "body": {},
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 200 with no token", "curl": true }
  },
  {
    "name": "token-with-a-space",
    "body": { "access_token": "a b", "expires_in": 3599 },
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 200 with no token", "curl": true }
  },
  {
    "name": "token-that-is-not-a-string",
    "body": { "access_token": 5, "expires_in": 3599 },
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 200 with no token", "curl": true }
  },
  {
    "name": "no-expiry",
    "body": { "access_token": "abc.def.ghi" },
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 200 with no token", "curl": true }
  },
  {
    "name": "zero-expiry",
    "body": { "access_token": "abc.def.ghi", "expires_in": 0 },
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 200 with no token", "curl": true }
  },
  {
    "name": "answer-that-is-not-json",
    "body": "nope",
    "want": { "exit": 1, "stderr": "microsoft-mint: the token endpoint answered 200 with no token", "curl": true }
  },
  {
    "name": "tenant-with-a-slash",
    "about": "The tenant is a path segment of the token address. Each character that could leave the segment, or turn it into another address, has a case of its own, since a case with two of them stays refused when one is let through.",
    "set": { "MICROSOFT_TENANT": "evil.example/x" },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "tenant-with-a-question-mark",
    "set": { "MICROSOFT_TENANT": "evil.example?x" },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "tenant-with-a-hash",
    "set": { "MICROSOFT_TENANT": "evil.example#x" },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "tenant-with-an-at-sign",
    "set": { "MICROSOFT_TENANT": "a@evil.example" },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "tenant-with-a-colon",
    "set": { "MICROSOFT_TENANT": "evil.example:8080" },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "tenant-with-a-percent",
    "set": { "MICROSOFT_TENANT": "evil%2Fexample" },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "tenant-with-a-space",
    "set": { "MICROSOFT_TENANT": "evil example" },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "tenant-with-a-line-break",
    "set": { "MICROSOFT_TENANT": "evil\nexample" },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "tenant-with-a-leading-dash",
    "set": { "MICROSOFT_TENANT": "-x" },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "tenant-that-is-dots",
    "set": { "MICROSOFT_TENANT": ".." },
    "want": { "exit": 1, "stderr": "microsoft-mint: MICROSOFT_TENANT is no tenant id or domain name", "curl": false }
  },
  {
    "name": "no-tenant",
    "drop": [ "MICROSOFT_TENANT" ],
    "want": { "exit": 1, "stderr": "microsoft-mint: its inputs name no MICROSOFT_TENANT", "curl": false }
  },
  {
    "name": "empty-tenant",
    "set": { "MICROSOFT_TENANT": "" },
    "want": { "exit": 1, "stderr": "microsoft-mint: its inputs name no MICROSOFT_TENANT", "curl": false }
  },
  {
    "name": "no-client-id",
    "drop": [ "MICROSOFT_CLIENT_ID" ],
    "want": { "exit": 1, "stderr": "microsoft-mint: its inputs name no MICROSOFT_CLIENT_ID", "curl": false }
  },
  {
    "name": "no-secret",
    "drop": [ "MICROSOFT_CLIENT_SECRET" ],
    "want": { "exit": 1, "stderr": "microsoft-mint: its inputs name no MICROSOFT_CLIENT_SECRET", "curl": false }
  },
  {
    "name": "secret-that-is-not-a-string",
    "set": { "MICROSOFT_CLIENT_SECRET": 5 },
    "want": { "exit": 1, "stderr": "microsoft-mint: its inputs name no MICROSOFT_CLIENT_SECRET", "curl": false }
  },
  {
    "name": "empty-stdin",
    "stdin": "",
    "want": { "exit": 1, "stderr": "microsoft-mint: reads {inputs, scope} on stdin, and got nothing", "curl": false }
  },
  {
    "name": "stdin-with-no-inputs",
    "stdin": "{\"scope\":\"\"}",
    "want": { "exit": 1, "stderr": "microsoft-mint: reads {inputs, scope} on stdin, and got no inputs", "curl": false }
  },
  {
    "name": "stdin-that-is-not-json",
    "stdin": "nope",
    "want": { "exit": 1, "stderr": "microsoft-mint: reads {inputs, scope} on stdin, and got no inputs", "curl": false }
  },
  {
    "name": "curl-cannot-connect",
    "curl_exit": 7,
    "want": { "exit": 1, "stderr": "microsoft-mint: could not reach login.microsoftonline.com (curl exit 7, no connection; behind a proxy, HTTPS_PROXY names it)", "curl": true }
  },
  {
    "name": "curl-cannot-resolve",
    "curl_exit": 6,
    "want": { "exit": 1, "stderr": "microsoft-mint: could not reach login.microsoftonline.com (curl exit 6, no connection; behind a proxy, HTTPS_PROXY names it)", "curl": true }
  },
  {
    "name": "curl-times-out",
    "curl_exit": 28,
    "want": { "exit": 1, "stderr": "microsoft-mint: could not reach login.microsoftonline.com (curl exit 28, no answer in 20 seconds)", "curl": true }
  },
  {
    "name": "curl-does-not-trust-the-certificate",
    "curl_exit": 60,
    "want": { "exit": 1, "stderr": "microsoft-mint: could not reach login.microsoftonline.com (curl exit 60, TLS failed; a proxy that inspects TLS needs its CA in SSL_CERT_FILE)", "curl": true }
  },
  {
    "name": "curl-fails-another-way",
    "curl_exit": 22,
    "want": { "exit": 1, "stderr": "microsoft-mint: could not reach login.microsoftonline.com (curl exit 22)", "curl": true }
  }
]
--silent
--max-time
20
--proto
=https
--tlsv1.2
--header
Content-Type: application/x-www-form-urlencoded
--data-binary
@-
--write-out
\n%{http_code}
https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/token
client_id=00001111-aaaa-2222-bbbb-3333cccc4444&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&client_secret=p%26ss%2BSEKRET9f3c%2Bw0rd%2F%3D%2520%20%22q%22%20%5C%20%C3%BCn%C3%AF%20%3Cx%3E%20~._-%C3%A9&grant_type=client_credentials
{"inputs":{"MICROSOFT_CLIENT_ID":"00001111-aaaa-2222-bbbb-3333cccc4444","MICROSOFT_CLIENT_SECRET":"p\u0026ss+SEKRET9f3c+w0rd/=%20 \"q\" \\ ünï \u003cx\u003e ~._-é","MICROSOFT_TENANT":"contoso.onmicrosoft.com"},"scope":""}
# The request yardr makes for a call of microsoft.yaml, and the answer it
# keeps, built from the declaration as data. Input: {decl, call, given,
# answer}, where decl is microsoft.yaml read as JSON, given the call's
# parameters as the command line gives them (name=value, so all strings)
# and answer a recorded Graph answer. Output: {request, kept, bound}, or
# {error} where yardr refuses the call before sending it.
#
# It follows internal/source/call.go of yardr, read on 2 November 2026,
# and stands in for it only where this pack's declaration uses it:
#   values       each parameter is held to its type, max and pattern, in
#                the order declared; a default stands in for a parameter
#                not given, and an optional one with none is left out
#   target       each {param} in the path becomes one escaped segment (the
#                values here never hold more than jq's @uri and Go's
#                url.PathEscape both escape alike); the query is sorted by
#                key and each side escaped as url.QueryEscape does, a space
#                being a +; a pair whose value is exactly {param} is left
#                out when the parameter was not given
#   fill         a body string that is exactly {param} becomes the
#                parameter's typed value, and is left out when the
#                parameter was not given; any other string has its
#                {param}s replaced by the text
#   headers      Accept, User-Agent, Content-Type with a body, and the
#                credential's header, with <token> for the credential
#   selected     an object answer keeps the named top-level keys
# yardr's own tests run these against a stand-in server; nothing here
# does, so it can only be as faithful as the reading above.

def mark: "\\{(?<n>[A-Za-z_][A-Za-z0-9_]*)\\}";
def exact: "^\\{(?<n>[A-Za-z_][A-Za-z0-9_]*)\\}$";

# a body with its {param}s filled; empty stands for "left out"
def fill($vals):
  if type == "object" then
    [to_entries[] | .key as $k | (.value | fill($vals)) as $v | {key: $k, value: $v}] | from_entries
  elif type == "array" then
    [.[] | fill($vals)]
  elif type == "string" then
    if . == "{credential}" then "<token>"
    elif test(exact) then
      capture(exact).n as $n | if $vals[$n] != null then $vals[$n].typed else empty end
    else gsub(mark; $vals[.n].text // "") end
  else . end;

def render:
  .decl as $d | .call as $name | (.given // {}) as $given
  | ($d.calls[$name]) as $c
  | ($c.params // {}) as $params
  | if $c == null then error("the source has no call of that name") else . end
  # values
  | ([$given | keys_unsorted[] | select($params[.] == null)] | first // null) as $unknown
  | if $unknown != null then
      error("\($unknown | tojson) is no parameter of the call (it takes: \($params | keys_unsorted | join(", ") | if . == "" then "none" else . end))")
    else . end
  | ([ $params | to_entries[] | .key as $n | .value as $p
       | ($given[$n] // (if $p | has("default") then $p.default | tostring else null end)) as $s
       | if $s == null then
           (if $p.optional // false then empty else error("parameter \($n) is missing") end)
         else
           (if $p.type == "string" then
              (if $p.max != null and ($s | length) > $p.max then
                 error("parameter \($n) is \($s | length) characters, and at most \($p.max)")
               elif $p.pattern != null and ($s | test($p.pattern) | not) then
                 error("parameter \($n) does not match its pattern \($p.pattern)")
               else null end)
            elif $p.type == "int" then
              (if ($s | test("^[-+]?[0-9]+$") | not) then error("parameter \($n) is no whole number") else null end)
            elif $p.type == "bool" then
              (if $s != "true" and $s != "false" then error("parameter \($n) is true or false") else null end)
            elif $p.type == "enum" then
              (if ($p.values | index($s)) == null then error("parameter \($n) is one of \($p.values | join(", "))") else null end)
            else error("parameter \($n) has no type") end) as $_
           | {key: $n, value: {text: $s, typed: (if $p.type == "int" then ($s | tonumber) elif $p.type == "bool" then ($s == "true") else $s end)}}
         end
     ] | from_entries) as $vals
  # target
  | ($c.request.path | split("/")) as $segs
  | (if any($segs[]; test(mark) and ((gsub(mark; $vals[.n].text // "")) | . == "." or . == "..")) then
       error("a parameter makes a segment of the path that leaves the source's base")
     else . end)
  | ($segs | map(if test(mark) then gsub(mark; ($vals[.n].text // "") | @uri) else . end) | join("/")) as $path
  | ([ ($c.request.query // {}) | to_entries[]
       | .key as $k | .value as $v
       | if ($v | test(exact)) and ($vals[($v | capture(exact).n)] == null) then empty
         else {k: $k, v: ($v | gsub(mark; $vals[.n].text // ""))} end
     ] | sort_by(.k)
       | map("\(.k | @uri | gsub("%20"; "+"))=\(.v | @uri | gsub("%20"; "+"))")
       | join("&")) as $query
  | (($d.base | rtrimstr("/")) + $path + (if $query == "" then "" else "?" + $query end)) as $url
  # body and headers
  | ($c.request.body | if . == null then null else fill($vals) end) as $body
  | ($d.credential.header | capture("^(?<k>[^:]+): (?<v>.*)$")) as $auth
  | ({"Accept": "application/json", "User-Agent": "yardr"}
     + (if $body != null then {"Content-Type": "application/json"} else {} end)
     + {($auth.k): ($auth.v | sub("\\{credential\\}"; "<token>"))}) as $headers
  # the answer
  | ($c.answer.select // []) as $keys
  | (.answer | if ($keys | length) > 0 and type == "object"
               then with_entries(select(.key as $k | $keys | index($k) != null))
               else . end) as $kept
  | ($c.answer.max // 65536) as $bound
  | ($kept | tojson | utf8bytelength) as $len
  | if $len > $bound then error("the answer is \($len) bytes, over the call's bound of \($bound)") else . end
  | {request: ({method: $c.request.method, url: $url, headers: $headers} + (if $body != null then {body: $body} else {} end)),
     kept: $kept, bound: $bound};

try render catch {error: .}