From d6a4ac4f8f9265885c5e70869012cb5f8a03f8ed Mon Sep 17 00:00:00 2001 From: Greg Joseph Date: Wed, 22 Apr 2026 13:50:16 -0700 Subject: [PATCH] feat(ai): add SharePoint Embedded agent skills for autonomous setup Adds an Agent Skills package (agentskills.io spec) that enables AI coding agents to set up a complete SharePoint Embedded environment autonomously. What it does (Steps 1-5): 1. Azure CLI login (interactive browser) 2. Entra app registration with SPE permissions (public client, no secrets) 3. SPE token via interactive browser login (auth code + PKCE, device code fallback) 4. Container type creation + tenant registration 5. Container creation, activation, proof file upload, preview link Security: - Interactive auth (auth code + PKCE) as primary flow, not device code - BYO App validates public client before auth - Read-Host confirmation before all delete operations - ExecutionPolicy consent check before running - v1.0 Graph API endpoints where available (beta-only endpoints documented) Usage: Give an AI agent: 'Read AI/skills/full-setup/SKILL.md and set up SPE on my tenant' Or run manually: cd AI/skills/full-setup && .\spe-setup.ps1 Prerequisites: Azure CLI, PowerShell 5.1+, tenant admin access --- AI/README.md | 1 + AI/skills/SKILL.md | 58 +++++ AI/skills/full-setup/01-auth.ps1 | 50 ++++ AI/skills/full-setup/02-app.ps1 | 122 ++++++++++ AI/skills/full-setup/03-token.ps1 | 256 +++++++++++++++++++++ AI/skills/full-setup/04-container-type.ps1 | 80 +++++++ AI/skills/full-setup/05-container.ps1 | 157 +++++++++++++ AI/skills/full-setup/06-cleanup.ps1 | 61 +++++ AI/skills/full-setup/SKILL.md | 67 ++++++ AI/skills/full-setup/_common.ps1 | 155 +++++++++++++ AI/skills/full-setup/gotchas.md | 15 ++ AI/skills/full-setup/spe-setup.ps1 | 76 ++++++ AI/skills/reference/auth.md | 146 ++++++++++++ AI/skills/reference/graph-api-reference.md | 252 ++++++++++++++++++++ 14 files changed, 1496 insertions(+) create mode 100644 AI/skills/SKILL.md create mode 100644 AI/skills/full-setup/01-auth.ps1 create mode 100644 AI/skills/full-setup/02-app.ps1 create mode 100644 AI/skills/full-setup/03-token.ps1 create mode 100644 AI/skills/full-setup/04-container-type.ps1 create mode 100644 AI/skills/full-setup/05-container.ps1 create mode 100644 AI/skills/full-setup/06-cleanup.ps1 create mode 100644 AI/skills/full-setup/SKILL.md create mode 100644 AI/skills/full-setup/_common.ps1 create mode 100644 AI/skills/full-setup/gotchas.md create mode 100644 AI/skills/full-setup/spe-setup.ps1 create mode 100644 AI/skills/reference/auth.md create mode 100644 AI/skills/reference/graph-api-reference.md diff --git a/AI/README.md b/AI/README.md index 14243ad..d448de3 100644 --- a/AI/README.md +++ b/AI/README.md @@ -4,6 +4,7 @@ Samples and assets for integrating SharePoint Embedded with AI tools and service | Folder | Description | |--------|-------------| +| [skills](./skills) | Agent skills for autonomous SPE setup — interactive auth, container types, containers, and more | | [mcp-server](./mcp-server) | MCP server exposing 60+ SharePoint Embedded tools to AI coding tools (Claude, Cursor, GitHub Copilot) | | [ocr](./ocr) | Webhook-triggered document processing using Azure Document Intelligence | | [copilot](./copilot) | Microsoft Copilot extensibility assets | diff --git a/AI/skills/SKILL.md b/AI/skills/SKILL.md new file mode 100644 index 0000000..9368cda --- /dev/null +++ b/AI/skills/SKILL.md @@ -0,0 +1,58 @@ +--- +name: sharepoint-embedded +description: Entry point for all SharePoint Embedded operations - setup, container management, content operations, and billing. Routes to the appropriate sub-skill. Use when working with SPE, SharePoint Embedded containers, container types, file storage containers, or Microsoft Graph storage APIs. +--- + +# SharePoint Embedded + +AI agent skills for SharePoint Embedded — from initial setup to day-2 operations. + +## Skills + +| Skill | When to Use | +|-------|-------------| +| [full-setup/](full-setup/SKILL.md) | First-time environment setup (Entra app, container type, container) | +| [container-management/](container-management/SKILL.md) | Day-2 container operations (list, inspect, archive, delete, permissions) | +| [content-operations/](content-operations/SKILL.md) | File and folder operations inside containers | +| [billing-setup/](billing-setup/SKILL.md) | Production billing configuration | + +## Quick Start + +Give an agent this prompt: + +``` +Read Skills/full-setup/SKILL.md and run the SPE setup scripts to set up SharePoint Embedded on my tenant. +``` + +Or run it yourself: + +```powershell +cd Skills/full-setup +.\spe-setup.ps1 +``` + +## Prerequisites + +- Azure CLI (`az --version`) +- PowerShell 5.1+ or 7+ +- Tenant admin access (Global Admin or Application Admin) + +## Auth Architecture + +Two-moment auth — see [reference/auth.md](reference/auth.md) for details. + +- **Moment 1 (Bootstrap):** `az login` for admin-level Entra app creation +- **Moment 2 (SPE Token):** Device code flow for delegated SPE scopes + +## Reference + +- **Auth flow details + fallbacks:** [reference/auth.md](reference/auth.md) +- **Graph API endpoints + payloads + errors:** [reference/graph-api-reference.md](reference/graph-api-reference.md) + +## References + +- [SharePoint Embedded Getting Started](https://learn.microsoft.com/en-us/sharepoint/dev/embedded/getting-started/register-api-documentation) +- [Graph API: Container Types](https://learn.microsoft.com/en-us/graph/api/resources/filestoragecontainertype?view=graph-rest-beta) +- [Graph API: Containers](https://learn.microsoft.com/en-us/graph/api/resources/filestoragecontainer?view=graph-rest-beta) +- [Entra App Registration](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) +- [Device Code Flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code) diff --git a/AI/skills/full-setup/01-auth.ps1 b/AI/skills/full-setup/01-auth.ps1 new file mode 100644 index 0000000..b1fdf95 --- /dev/null +++ b/AI/skills/full-setup/01-auth.ps1 @@ -0,0 +1,50 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Step 1: Azure CLI authentication for SharePoint Embedded setup. +.DESCRIPTION + Signs into Azure CLI and saves tenant info to .env.spe. + If already signed in, reuses the existing session. +#> + +. "$PSScriptRoot\_common.ps1" + +Write-Host "`n=== Step 1: Azure CLI Authentication ===" -ForegroundColor Cyan + +$azAccount = $null +try { + $azAccount = az account show --query "{tenantId:tenantId, user:user.name}" -o json 2>$null | ConvertFrom-Json +} catch {} + +if (-not $azAccount) { + Write-Host " Signing into Azure CLI..." -ForegroundColor Yellow + Write-Host " A browser window will open for authentication." -ForegroundColor Gray + Write-Host " If no browser opens, use: az login --allow-no-subscriptions --use-device-code" -ForegroundColor Gray + Write-Host "" + $prevPref = $ErrorActionPreference + $ErrorActionPreference = "Continue" + az login --allow-no-subscriptions 2>&1 | Out-Null + $ErrorActionPreference = $prevPref + $azAccount = az account show --query "{tenantId:tenantId, user:user.name}" -o json | ConvertFrom-Json + if (-not $azAccount) { + throw "Azure CLI login failed. Please run 'az login --allow-no-subscriptions' manually." + } +} + +$tenantId = $azAccount.tenantId + +# Save to .env.spe +$spe = Read-EnvFile +$spe["TENANT_ID"] = $tenantId +$spe["USER_UPN"] = $azAccount.user +Save-EnvFile $spe + +Write-Host "" +Write-Host "=== RESULT ===" -ForegroundColor Green +Write-Host "Status: OK" +Write-Host "User: $($azAccount.user)" +Write-Host "Tenant: $tenantId" +Write-Host "Entra Portal: https://entra.microsoft.com/$tenantId/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade" +Write-Host "=== END ===" -ForegroundColor Green +Write-Host "[AGENT] STOP. Present the RESULT block above to the user as a markdown table. Do NOT run the next script until the user replies." -ForegroundColor DarkGray +Write-Host "" diff --git a/AI/skills/full-setup/02-app.ps1 b/AI/skills/full-setup/02-app.ps1 new file mode 100644 index 0000000..e221610 --- /dev/null +++ b/AI/skills/full-setup/02-app.ps1 @@ -0,0 +1,122 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Step 2-3: Create Entra app registration and add SPE API permissions. +.PARAMETER AppDisplayName + Display name for the app registration. Default: "My SPE App" +#> + +param( + [string]$AppDisplayName = "My SPE App" +) + +. "$PSScriptRoot\_common.ps1" + +$spe = Assert-EnvKeys @("TENANT_ID") +$tenantId = $spe["TENANT_ID"] +$headers = Get-BootstrapHeaders +$portalBase = "https://portal.azure.com/$tenantId/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade" + +# ── Step 2: Create or find Entra app ────────────────────────────────────────── +Write-Host "`n=== Step 2: Entra App Registration ===" -ForegroundColor Cyan + +$app = $null + +# Check for app from previous run +if ($spe["CLIENT_ID"]) { + try { + $existing = Invoke-GraphRequest -Uri "$GraphBase/v1.0/applications?`$filter=appId eq '$($spe["CLIENT_ID"])'" -Headers $headers + $app = $existing.value | Select-Object -First 1 + if ($app) { Write-Host " Found app from previous run: $($app.appId)" -ForegroundColor Gray } + } catch {} +} + +# Fall back to displayName lookup +if (-not $app) { + $existing = Invoke-GraphRequest -Uri "$GraphBase/v1.0/applications?`$filter=displayName eq '$AppDisplayName'" -Headers $headers + $app = $existing.value | Select-Object -First 1 +} + +if ($app) { + Write-Host " App already exists: $($app.appId)" -ForegroundColor Green +} else { + Write-Host " Creating public client app '$AppDisplayName'..." -ForegroundColor Gray + $appBody = @{ + displayName = $AppDisplayName + signInAudience = "AzureADMyOrg" + isFallbackPublicClient = $true + publicClient = @{ redirectUris = @("http://localhost:3000") } + } | ConvertTo-Json -Depth 5 + + $app = Invoke-GraphRequest -Uri "$GraphBase/v1.0/applications" -Method POST -Headers $headers -Body $appBody + Write-Host " App created: $($app.appId)" -ForegroundColor Green +} + +$appId = $app.appId +$appObjectId = $app.id + +# Ensure publicClient redirect URI and isFallbackPublicClient are set (fixes existing apps) +$needsPatch = $false +$patchBody = @{} +if (-not $app.isFallbackPublicClient) { + $patchBody["isFallbackPublicClient"] = $true + $needsPatch = $true +} +$currentRedirects = @() +if ($app.publicClient -and $app.publicClient.redirectUris) { + $currentRedirects = $app.publicClient.redirectUris +} +if ("http://localhost:3000" -notin $currentRedirects) { + $patchBody["publicClient"] = @{ redirectUris = @("http://localhost:3000") } + $needsPatch = $true +} +# Remove SPA redirect if present (causes AADSTS9002327 on server-side token exchange) +if ($app.spa -and $app.spa.redirectUris -and $app.spa.redirectUris.Count -gt 0) { + $patchBody["spa"] = @{ redirectUris = @() } + $needsPatch = $true +} +if ($needsPatch) { + $patchJson = $patchBody | ConvertTo-Json -Depth 5 + Invoke-GraphRequest -Uri "$GraphBase/v1.0/applications/$($app.id)" -Method PATCH -Headers $headers -Body $patchJson + Write-Host " Updated app: publicClient redirect URI and settings configured" -ForegroundColor Green +} + +# ── Step 3: Add API permissions ─────────────────────────────────────────────── +Write-Host "`n=== Step 3: API Permissions ===" -ForegroundColor Cyan + +$permBody = @{ + requiredResourceAccess = @( + @{ + resourceAppId = "00000003-0000-0000-c000-000000000000" + resourceAccess = @( + @{ id = $PERMS.FileStorageContainer_Selected_Delegated; type = "Scope" } + @{ id = $PERMS.FileStorageContainerType_ManageAll_Delegated; type = "Scope" } + @{ id = $PERMS.FileStorageContainerTypeReg_ManageAll_Delegated; type = "Scope" } + ) + } + ) +} | ConvertTo-Json -Depth 5 + +Invoke-GraphRequest -Uri "$GraphBase/v1.0/applications/$appObjectId" -Method PATCH -Headers $headers -Body $permBody +Write-Host " Permissions configured" -ForegroundColor Green + +# Save state +$spe["CLIENT_ID"] = $appId +$spe["APP_OBJECT_ID"] = $appObjectId +$spe["APP_PORTAL"] = "$portalBase/~/Overview/appId/$appId" +$spe["PERMISSIONS_PORTAL"] = "$portalBase/~/CallAnAPI/appId/$appId" +Save-EnvFile $spe + +Write-Host "" +Write-Host "=== RESULT ===" -ForegroundColor Green +Write-Host "Status: OK" +Write-Host "App Name: $AppDisplayName" +Write-Host "Application ID: $appId" +Write-Host "Object ID: $appObjectId" +Write-Host "Public Client: Yes (no secret needed)" +Write-Host "Permissions: FileStorageContainer.Selected, ContainerType.Manage.All, ContainerTypeReg.Manage.All" +Write-Host "View App: $portalBase/~/Overview/appId/$appId" +Write-Host "View Permissions: $portalBase/~/CallAnAPI/appId/$appId" +Write-Host "=== END ===" -ForegroundColor Green +Write-Host "[AGENT] STOP. Present the RESULT block above to the user as a markdown table. Do NOT run the next script until the user replies." -ForegroundColor DarkGray +Write-Host "" diff --git a/AI/skills/full-setup/03-token.ps1 b/AI/skills/full-setup/03-token.ps1 new file mode 100644 index 0000000..b4added --- /dev/null +++ b/AI/skills/full-setup/03-token.ps1 @@ -0,0 +1,256 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Step 4: Interactive authentication for SPE-scoped token. +.DESCRIPTION + Acquires a token via interactive auth code + PKCE flow (opens browser). + Falls back to device code flow if browser cannot be launched. + Token is cached to .spe-token for reuse by subsequent scripts. + If a valid cached token exists, skips auth entirely. +.PARAMETER UseDeviceCode + Force device code flow instead of interactive browser auth. +#> + +param( + [switch]$UseDeviceCode +) + +. "$PSScriptRoot\_common.ps1" + +$spe = Assert-EnvKeys @("TENANT_ID", "CLIENT_ID") +$tenantId = $spe["TENANT_ID"] +$appId = $spe["CLIENT_ID"] +$tokenPath = Join-Path (Get-Location) ".spe-token" +$scopes = "FileStorageContainer.Selected FileStorageContainerType.Manage.All FileStorageContainerTypeReg.Manage.All" +$redirectUri = "http://localhost:3000" + +Write-Host "`n=== Step 4: SPE Authentication ===" -ForegroundColor Cyan + +# Validate that the app is a public client +try { + $bootstrapHeaders = Get-BootstrapHeaders + $appDetails = Invoke-GraphRequest -Uri "$GraphBase/v1.0/applications?`$filter=appId eq '$appId'" -Headers $bootstrapHeaders + $appObj = $appDetails.value | Select-Object -First 1 + if ($appObj -and $appObj.isFallbackPublicClient -ne $true) { + Write-Host " WARNING: App '$appId' is not configured as a public client (isFallbackPublicClient = false)." -ForegroundColor Red + Write-Host " Interactive and device code flows only work with public client applications." -ForegroundColor Red + throw "App '$appId' is a confidential client. Auth requires a public client application." + } +} catch [System.Management.Automation.RuntimeException] { + if ($_ -match "confidential client") { throw } + Write-Host " Could not verify app client type (bootstrap token may be unavailable). Proceeding..." -ForegroundColor Yellow +} + +# Check for cached token +$speToken = $null +if (Test-Path $tokenPath) { + $cachedToken = (Get-Content $tokenPath -Raw).Trim() + if ($cachedToken) { + try { + $payload = $cachedToken.Split('.')[1] + switch ($payload.Length % 4) { + 2 { $payload += '==' } + 3 { $payload += '=' } + } + $decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json + $expiry = [DateTimeOffset]::FromUnixTimeSeconds($decoded.exp).LocalDateTime + if ($expiry -gt (Get-Date).AddMinutes(2)) { + $speToken = $cachedToken + Write-Host " Using cached SPE token (expires $($expiry.ToString('HH:mm:ss')))" -ForegroundColor Green + } else { + Write-Host " Cached token expired. Starting authentication..." -ForegroundColor Yellow + } + } catch { + Write-Host " Could not read cached token. Starting authentication..." -ForegroundColor Yellow + } + } +} + +$authMethod = "Cached" + +if (-not $speToken) { + if (-not $UseDeviceCode) { + # ── Interactive auth code + PKCE flow ───────────────────────────────── + Write-Host " Attempting interactive browser login..." -ForegroundColor Gray + + # Generate PKCE code verifier and challenge + $codeVerifierBytes = New-Object byte[] 32 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($codeVerifierBytes) + $codeVerifier = [Convert]::ToBase64String($codeVerifierBytes) -replace '\+','-' -replace '/','_' -replace '=' + $sha256 = [System.Security.Cryptography.SHA256]::Create() + $challengeBytes = $sha256.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($codeVerifier)) + $codeChallenge = [Convert]::ToBase64String($challengeBytes) -replace '\+','-' -replace '/','_' -replace '=' + + # Generate state for CSRF protection + $stateBytes = New-Object byte[] 16 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($stateBytes) + $state = [Convert]::ToBase64String($stateBytes) -replace '\+','-' -replace '/','_' -replace '=' + + # Load System.Web for query string parsing (not loaded by default in PS 5.1) + try { Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue } catch {} + + # Start localhost listener + $port = 3000 + $listener = $null + try { + $listener = New-Object System.Net.HttpListener + $listener.Prefixes.Add("http://localhost:${port}/") + $listener.Start() + } catch { + Write-Host " Could not start listener on port $port. Falling back to device code flow..." -ForegroundColor Yellow + $UseDeviceCode = $true + } + + if ($listener -and $listener.IsListening) { + # Build authorization URL + $encodedScopes = [System.Uri]::EscapeDataString($scopes) + $encodedRedirect = [System.Uri]::EscapeDataString($redirectUri) + $authUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?" + + "client_id=$appId" + + "&response_type=code" + + "&redirect_uri=$encodedRedirect" + + "&scope=$encodedScopes" + + "&state=$state" + + "&code_challenge=$codeChallenge" + + "&code_challenge_method=S256" + + "&prompt=select_account" + + # Open browser + Write-Host "" + Write-Host " Opening browser for sign-in..." -ForegroundColor White + try { + Start-Process $authUrl + } catch { + Write-Host " Could not open browser. Falling back to device code flow..." -ForegroundColor Yellow + $listener.Stop() + $listener.Close() + $UseDeviceCode = $true + } + } + + if ($listener -and $listener.IsListening -and -not $UseDeviceCode) { + Write-Host " Waiting for sign-in callback on http://localhost:${port}/ ..." -ForegroundColor Gray + Write-Host " (If the browser did not open, visit the URL above manually)" -ForegroundColor Gray + + # Wait for the callback (timeout after 300 seconds) + $asyncResult = $listener.BeginGetContext($null, $null) + $waitResult = $asyncResult.AsyncWaitHandle.WaitOne(300000) + + if (-not $waitResult) { + Write-Host " Timed out waiting for browser callback. Falling back to device code flow..." -ForegroundColor Yellow + $listener.Stop() + $listener.Close() + $UseDeviceCode = $true + } else { + $context = $listener.EndGetContext($asyncResult) + $request = $context.Request + $response = $context.Response + + # Parse the callback + $queryParams = [System.Web.HttpUtility]::ParseQueryString($request.Url.Query) + $authCode = $queryParams["code"] + $returnedState = $queryParams["state"] + $authError = $queryParams["error"] + + # Send success page to browser + $html = "

Authentication successful!

You can close this window and return to your terminal.

" + if ($authError) { + $html = "

Authentication failed

Error: $authError

Close this window and check your terminal.

" + } + $htmlBytes = [System.Text.Encoding]::UTF8.GetBytes($html) + $response.ContentLength64 = $htmlBytes.Length + $response.ContentType = "text/html" + $response.OutputStream.Write($htmlBytes, 0, $htmlBytes.Length) + $response.OutputStream.Close() + + $listener.Stop() + $listener.Close() + + if ($authError) { + $errorDesc = $queryParams["error_description"] + Write-Host " Auth error: $authError - $errorDesc" -ForegroundColor Red + Write-Host " Falling back to device code flow..." -ForegroundColor Yellow + $UseDeviceCode = $true + } elseif ($returnedState -ne $state) { + Write-Host " State mismatch (possible CSRF). Falling back to device code flow..." -ForegroundColor Red + $UseDeviceCode = $true + } elseif ($authCode) { + # Exchange auth code for token + $tokenBody = "client_id=$appId" + + "&grant_type=authorization_code" + + "&code=$authCode" + + "&redirect_uri=$encodedRedirect" + + "&code_verifier=$codeVerifier" + + try { + $tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" -Method POST -Body $tokenBody + $speToken = $tokenResponse.access_token + $authMethod = "Interactive (Browser)" + Write-Host " SPE token acquired via interactive login" -ForegroundColor Green + } catch { + $errMsg = $_.ErrorDetails.Message + Write-Host " Token exchange failed: $errMsg" -ForegroundColor Red + Write-Host " Falling back to device code flow..." -ForegroundColor Yellow + $UseDeviceCode = $true + } + } else { + Write-Host " No auth code received. Falling back to device code flow..." -ForegroundColor Yellow + $UseDeviceCode = $true + } + } + } + } + + # ── Device code flow (fallback or explicit) ─────────────────────────────── + if (-not $speToken -and $UseDeviceCode) { + Write-Host " Using device code flow..." -ForegroundColor Gray + $deviceCodeBody = "client_id=$appId&scope=$scopes" + $deviceCode = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/devicecode" -Method POST -Body $deviceCodeBody + + Write-Host "" + Write-Host " ACTION REQUIRED:" -ForegroundColor Yellow + Write-Host " $($deviceCode.message)" -ForegroundColor White + Write-Host " (First time: you will see a consent prompt - click Accept)" -ForegroundColor Gray + Write-Host "" + + $pollBody = "client_id=$appId&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$($deviceCode.device_code)" + $pollInterval = [Math]::Max([int]$deviceCode.interval, 5) + + while (-not $speToken) { + Start-Sleep -Seconds $pollInterval + try { + $tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" -Method POST -Body $pollBody + $speToken = $tokenResponse.access_token + $authMethod = "Device Code" + } catch { + $errBody = $null + try { $errBody = $_.ErrorDetails.Message | ConvertFrom-Json } catch {} + if ($errBody -and $errBody.error -eq "authorization_pending") { + Write-Host " Waiting for authorization..." -ForegroundColor Gray + } elseif ($errBody -and $errBody.error -eq "slow_down") { + $pollInterval += 5 + } else { + $errMsg = if ($errBody) { $errBody.error_description } else { $_.Exception.Message } + throw "Device code flow failed: $errMsg" + } + } + } + } + + if (-not $speToken) { + throw "Failed to acquire SPE token via any auth method." + } + + [System.IO.File]::WriteAllText($tokenPath, $speToken, [System.Text.UTF8Encoding]::new($false)) + Write-Host " SPE token cached to $tokenPath" -ForegroundColor Green +} + +Write-Host "" +Write-Host "=== RESULT ===" -ForegroundColor Green +Write-Host "Status: OK" +Write-Host "Auth Method: $authMethod" +Write-Host "Scopes: Container.Selected, ContainerType.Manage, ContainerTypeReg.Manage" +Write-Host "Token Cache: $tokenPath" +Write-Host "=== END ===" -ForegroundColor Green +Write-Host "[AGENT] STOP. Show the RESULT as a markdown table. Then ask the user to continue. Do NOT run the next script yet." -ForegroundColor DarkGray +Write-Host "" diff --git a/AI/skills/full-setup/04-container-type.ps1 b/AI/skills/full-setup/04-container-type.ps1 new file mode 100644 index 0000000..3147f0d --- /dev/null +++ b/AI/skills/full-setup/04-container-type.ps1 @@ -0,0 +1,80 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Step 5-6: Create container type and register on tenant. +.PARAMETER ContainerTypeName + Name for the container type. Default: "My Container Type" +.PARAMETER BillingClassification + "trial" (default) or "standard". +#> + +param( + [string]$ContainerTypeName = "My Container Type", + [string]$BillingClassification = "trial" +) + +. "$PSScriptRoot\_common.ps1" + +$spe = Assert-EnvKeys @("TENANT_ID", "CLIENT_ID") +$appId = $spe["CLIENT_ID"] +$speHeaders = Get-SpeHeaders + +# ── Step 5: Create or find container type ───────────────────────────────────── +Write-Host "`n=== Step 5: Container Type ===" -ForegroundColor Cyan + +$ctList = Invoke-GraphRequest -Uri "$GraphBase/v1.0/storage/fileStorage/containerTypes" -Headers $speHeaders +$ct = $ctList.value | Where-Object { $_.owningAppId -eq $appId } | Select-Object -First 1 + +if ($ct) { + Write-Host " Container type already exists: $($ct.id)" -ForegroundColor Green +} else { + Write-Host " Creating container type '$ContainerTypeName' (billing: $BillingClassification)..." -ForegroundColor Gray + # IMPORTANT: Graph API field is "name" NOT "displayName" + $ctBody = @{ + name = $ContainerTypeName + owningAppId = $appId + billingClassification = $BillingClassification + } | ConvertTo-Json + + $ct = Invoke-GraphRequest -Uri "$GraphBase/v1.0/storage/fileStorage/containerTypes" -Method POST -Headers $speHeaders -Body $ctBody + Write-Host " Container type created: $($ct.id)" -ForegroundColor Green +} + +# IMPORTANT: Graph response field is "id" NOT "containerTypeId" +$containerTypeId = $ct.id + +# ── Step 6: Register container type on tenant ───────────────────────────────── +Write-Host "`n=== Step 6: Container Type Registration ===" -ForegroundColor Cyan + +# PUT is idempotent - always call to ensure permissions are correct +# CRITICAL: Must include applicationPermissionGrants or container creation fails +Write-Host " Registering container type with app permissions..." -ForegroundColor Gray +$regBody = @{ + applicationPermissionGrants = @( + @{ + appId = $appId + delegatedPermissions = @("full") + applicationPermissions = @("full") + } + ) +} | ConvertTo-Json -Depth 5 + +Invoke-GraphRequest -Uri "$GraphBase/v1.0/storage/fileStorage/containerTypeRegistrations/$containerTypeId" -Method PUT -Headers $speHeaders -Body $regBody +Write-Host " Container type registered on tenant" -ForegroundColor Green + +# Save state +$spe["CONTAINER_TYPE_ID"] = $containerTypeId +Save-EnvFile $spe + +Write-Host "" +Write-Host "=== RESULT ===" -ForegroundColor Green +Write-Host "Status: OK" +Write-Host "Container Type ID: $containerTypeId" +Write-Host "Name: $ContainerTypeName" +Write-Host "Owning App: $appId" +Write-Host "Billing: $BillingClassification" +Write-Host "Delegated Permissions: full" +Write-Host "Application Permissions: full" +Write-Host "=== END ===" -ForegroundColor Green +Write-Host "[AGENT] STOP. Show the RESULT as a markdown table. Then ask the user to continue. Do NOT run the next script yet." -ForegroundColor DarkGray +Write-Host "" diff --git a/AI/skills/full-setup/05-container.ps1 b/AI/skills/full-setup/05-container.ps1 new file mode 100644 index 0000000..092d626 --- /dev/null +++ b/AI/skills/full-setup/05-container.ps1 @@ -0,0 +1,157 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Step 7-8: Create container, upload proof file, generate preview link. +.PARAMETER ContainerName + Display name for the container. Default: "My First Container" +#> + +param( + [string]$ContainerName = "My First Container" +) + +. "$PSScriptRoot\_common.ps1" + +$spe = Assert-EnvKeys @("TENANT_ID", "CLIENT_ID", "CONTAINER_TYPE_ID") +$tenantId = $spe["TENANT_ID"] +$appId = $spe["CLIENT_ID"] +$containerTypeId = $spe["CONTAINER_TYPE_ID"] +$speHeaders = Get-SpeHeaders +$bootstrapHeaders = Get-BootstrapHeaders + +# ── Step 7: Create and activate container ───────────────────────────────────── +Write-Host "`n=== Step 7: First Container ===" -ForegroundColor Cyan + +$container = $null +try { + $containers = Invoke-GraphRequest -Uri "$GraphBase/v1.0/storage/fileStorage/containers?`$filter=containerTypeId eq $containerTypeId" -Headers $speHeaders + $container = $containers.value | Where-Object { $_.displayName -eq $ContainerName } | Select-Object -First 1 +} catch { + Write-Host " Could not list containers yet (registration propagating). Will create." -ForegroundColor Yellow +} + +if ($container) { + Write-Host " Container already exists: $($container.id)" -ForegroundColor Green +} else { + $containerBody = @{ + containerTypeId = $containerTypeId + displayName = $ContainerName + } | ConvertTo-Json + + # Retry with backoff - registration can take 10-30s to propagate + for ($attempt = 1; $attempt -le 5; $attempt++) { + Write-Host " Creating container '$ContainerName' (attempt $attempt/5)..." -ForegroundColor Gray + try { + $container = Invoke-GraphRequest -Uri "$GraphBase/v1.0/storage/fileStorage/containers" -Method POST -Headers $speHeaders -Body $containerBody + Write-Host " Container created: $($container.id)" -ForegroundColor Green + break + } catch { + if ($attempt -lt 5) { + $waitSec = $attempt * 15 + Write-Host " Registration propagating. Waiting ${waitSec}s..." -ForegroundColor Yellow + Start-Sleep -Seconds $waitSec + } else { + throw "Container creation failed after 5 attempts: $_" + } + } + } +} + +$containerId = $container.id + +# Activate if needed (retry with backoff) +if ($container.status -ne "active") { + for ($attempt = 1; $attempt -le 5; $attempt++) { + Write-Host " Activating container (attempt $attempt/5)..." -ForegroundColor Gray + try { + Invoke-GraphRequest -Uri "$GraphBase/v1.0/storage/fileStorage/containers/$containerId/activate" -Method POST -Headers $speHeaders + Write-Host " Container activated" -ForegroundColor Green + break + } catch { + if ($_ -match "already active|activated") { + Write-Host " Container already active" -ForegroundColor Green + break + } elseif ($attempt -lt 5) { + $waitSec = $attempt * 10 + Write-Host " Waiting ${waitSec}s for propagation..." -ForegroundColor Yellow + Start-Sleep -Seconds $waitSec + } else { + Write-Host " Activation failed after 5 attempts: $_" -ForegroundColor Red + } + } + } +} + +# ── Step 8: Upload proof file, grant access, generate preview ───────────────── +Write-Host "`n=== Step 8: Proof File & Preview Link ===" -ForegroundColor Cyan + +$drive = Invoke-GraphRequest -Uri "$GraphBase/v1.0/storage/fileStorage/containers/$containerId/drive" -Headers $speHeaders +$driveId = $drive.id +Write-Host " Drive ID: $driveId" -ForegroundColor Gray + +$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +$proofContent = @" +=== SharePoint Embedded Setup Proof === +If you can see this file, the SPE setup completed successfully! + +App ID: $appId +Container Type: $containerTypeId +Container: $ContainerName ($containerId) +Created: $timestamp +"@ + +$proofFileName = "SPE-Setup-Proof.txt" +$uploadHeaders = @{ Authorization = $speHeaders.Authorization; "Content-Type" = "text/plain" } +$proofFile = Invoke-GraphRequest -Uri "$GraphBase/v1.0/drives/$driveId/root:/${proofFileName}:/content" -Method PUT -Headers $uploadHeaders -Body $proofContent +Write-Host " Uploaded: $proofFileName ($($proofFile.size) bytes)" -ForegroundColor Green + +# Grant current user owner permission on the container +Write-Host " Granting container owner permission to current user..." -ForegroundColor Gray +$me = Invoke-GraphRequest -Uri "$GraphBase/v1.0/me" -Headers $bootstrapHeaders +$permBody = @{ + roles = @("owner") + grantedToV2 = @{ + user = @{ + userPrincipalName = $me.userPrincipalName + } + } +} | ConvertTo-Json -Depth 5 + +try { + # Note: containers/{id}/permissions is beta-only (no v1.0 equivalent yet) + Invoke-RestMethod -Uri "$GraphBase/beta/storage/fileStorage/containers/$containerId/permissions" -Method POST -Headers $speHeaders -Body $permBody + Write-Host " Permission granted: $($me.userPrincipalName) = owner" -ForegroundColor Green +} catch { + $permErr = $_.ErrorDetails.Message + if ($permErr -match "already|exists|conflict|Forbidden") { + Write-Host " User already has container permissions (creator is auto-granted owner)" -ForegroundColor Green + } else { + Write-Host " Permission warning: $permErr" -ForegroundColor Yellow + } +} + +$preview = Invoke-GraphRequest -Uri "$GraphBase/v1.0/drives/$driveId/items/$($proofFile.id)/preview" -Method POST -Headers $speHeaders -Body "{}" +$previewUrl = $preview.getUrl + +# Save state +$spe["CONTAINER_ID"] = $containerId +$spe["DRIVE_ID"] = $driveId +$spe["PREVIEW_URL"] = $previewUrl +Save-EnvFile $spe + +Write-Host "" +Write-Host "=== RESULT ===" -ForegroundColor Green +Write-Host "Status: Setup Complete!" +Write-Host "Container ID: $containerId" +Write-Host "Container Name: $ContainerName" +Write-Host "Container Type ID: $containerTypeId" +Write-Host "Drive ID: $driveId" +Write-Host "Proof File: $proofFileName ($($proofFile.size) bytes)" +Write-Host "Owner: $($me.userPrincipalName)" +Write-Host "Preview URL: $previewUrl" +Write-Host "CLIENT_ID: $appId" +Write-Host "TENANT_ID: $tenantId" +Write-Host "CONTAINER_TYPE_ID: $containerTypeId" +Write-Host "=== END ===" -ForegroundColor Green +Write-Host "[AGENT] STOP. Present the RESULT block above to the user as a markdown table. This is the final step - congratulate the user!" -ForegroundColor DarkGray +Write-Host "" diff --git a/AI/skills/full-setup/06-cleanup.ps1 b/AI/skills/full-setup/06-cleanup.ps1 new file mode 100644 index 0000000..333b248 --- /dev/null +++ b/AI/skills/full-setup/06-cleanup.ps1 @@ -0,0 +1,61 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Step 9: Clean up SPE resources (container type + app registration). +.DESCRIPTION + Deletes the container type and Entra app created by the setup scripts. + Removes cached token and .env.spe files. + Only run this if you want to tear down everything that was created. +#> + +. "$PSScriptRoot\_common.ps1" + +$spe = Assert-EnvKeys @("TENANT_ID", "CLIENT_ID", "APP_OBJECT_ID", "CONTAINER_TYPE_ID") +$appObjectId = $spe["APP_OBJECT_ID"] +$containerTypeId = $spe["CONTAINER_TYPE_ID"] +$speHeaders = Get-SpeHeaders +$bootstrapHeaders = Get-BootstrapHeaders + +Write-Host "`n=== Step 9: Cleanup ===" -ForegroundColor Cyan + +Write-Host " This will DELETE the following resources:" -ForegroundColor Yellow +Write-Host " - Container Type: $containerTypeId" -ForegroundColor Yellow +Write-Host " - App Registration: $appObjectId" -ForegroundColor Yellow +Write-Host " - Local files: .spe-token and .env.spe" -ForegroundColor Yellow +$confirm = Read-Host " Are you sure you want to delete all SPE resources? (Y/N)" +if ($confirm -ne 'Y') { + Write-Host " Cleanup cancelled." -ForegroundColor Gray + return +} + +Write-Host " Deleting container type: $containerTypeId ..." -ForegroundColor Gray +try { + Invoke-GraphRequest -Uri "$GraphBase/v1.0/storage/fileStorage/containerTypes/$containerTypeId" -Method DELETE -Headers $speHeaders + Write-Host " Container type deleted" -ForegroundColor Green +} catch { + Write-Host " Container type deletion failed: $_" -ForegroundColor Red +} + +Write-Host " Deleting app registration: $appObjectId ..." -ForegroundColor Gray +try { + Invoke-GraphRequest -Uri "$GraphBase/v1.0/applications/$appObjectId" -Method DELETE -Headers $bootstrapHeaders + Write-Host " App registration deleted" -ForegroundColor Green +} catch { + Write-Host " App deletion failed: $_" -ForegroundColor Red +} + +# Clean up local files +$tokenPath = Join-Path (Get-Location) ".spe-token" +$envPath = Join-Path (Get-Location) ".env.spe" +if (Test-Path $tokenPath) { Remove-Item $tokenPath -Force } +if (Test-Path $envPath) { Remove-Item $envPath -Force } + +Write-Host "" +Write-Host "=== RESULT ===" -ForegroundColor Green +Write-Host "Status: OK" +Write-Host "Container Type: deleted ($containerTypeId)" +Write-Host "App Registration: deleted ($appObjectId)" +Write-Host "Local Files: .spe-token and .env.spe removed" +Write-Host "=== END ===" -ForegroundColor Green +Write-Host "[AGENT: Present the RESULT block above as a markdown table.]" -ForegroundColor DarkGray +Write-Host "" diff --git a/AI/skills/full-setup/SKILL.md b/AI/skills/full-setup/SKILL.md new file mode 100644 index 0000000..9d506ac --- /dev/null +++ b/AI/skills/full-setup/SKILL.md @@ -0,0 +1,67 @@ +--- +name: sharepoint-embedded-setup +description: Sets up a complete SharePoint Embedded environment - creates Entra app, container type, registration, container, and uploads a test file. Use when setting up SPE, creating container types, onboarding to SharePoint Embedded, or bootstrapping file storage containers. +--- + +# SharePoint Embedded — Full Setup + +## Workflow + +Run each script in order. State passes via `.env.spe`. + +``` +Setup Progress: +- [ ] 01-auth.ps1 -> Azure CLI login +- [ ] 02-app.ps1 -> Entra app + permissions +- [ ] 03-token.ps1 -> Device code auth (user interaction) +- [ ] 04-container-type.ps1 -> Container type + registration +- [ ] 05-container.ps1 -> Container + proof file + preview link +``` + +```powershell +powershell -File Skills/full-setup/01-auth.ps1 +powershell -File Skills/full-setup/02-app.ps1 +powershell -File Skills/full-setup/03-token.ps1 +powershell -File Skills/full-setup/04-container-type.ps1 +powershell -File Skills/full-setup/05-container.ps1 +``` + +## Output format + +Every script ends with a structured block: + +``` +=== RESULT === +Status: OK +Key: Value +View App: https://portal.azure.com/... +=== END === +``` + +Present **every line** from the RESULT block as a markdown table. Do not drop URL lines. + +## Step-specific notes + +**03-token.ps1** opens a browser for interactive sign-in (auth code + PKCE). If the browser cannot open, it falls back to device code flow and prints `ACTION REQUIRED:` with a URL and code. Use `-UseDeviceCode` to force device code flow. If `.spe-token` exists and is valid, this step completes instantly. + +**05-container.ps1** may take 30-60s due to propagation retries (handled automatically). After it finishes, read `.env.spe` and include `PREVIEW_URL` in the summary. + +**06-cleanup.ps1** deletes the container type and app. Only run if the user asks. The script prompts for Y/N confirmation before proceeding. + +## Customization + +```powershell +.\02-app.ps1 -AppDisplayName "Contoso Legal App" +.\04-container-type.ps1 -ContainerTypeName "Legal Cases" -BillingClassification "trial" +.\05-container.ps1 -ContainerName "Sample Case" +``` + +## Recovery + +All scripts are idempotent. Re-run any failed script. Delete `.env.spe` to start fresh. + +## Reference + +- **Edge cases and gotchas:** See [gotchas.md](gotchas.md) +- **Auth flow details:** See [../reference/auth.md](../reference/auth.md) +- **Graph API reference:** See [../reference/graph-api-reference.md](../reference/graph-api-reference.md) diff --git a/AI/skills/full-setup/_common.ps1 b/AI/skills/full-setup/_common.ps1 new file mode 100644 index 0000000..50b057f --- /dev/null +++ b/AI/skills/full-setup/_common.ps1 @@ -0,0 +1,155 @@ +# _common.ps1 - Shared utilities for SPE setup scripts +# Usage: . "$PSScriptRoot\_common.ps1" + +$ErrorActionPreference = "Stop" +$GraphBase = "https://graph.microsoft.com" + +# Check execution policy and prompt for consent if restricted +try { + $currentPolicy = Get-ExecutionPolicy -Scope CurrentUser + if ($currentPolicy -eq 'Restricted') { + Write-Host "ExecutionPolicy is '$currentPolicy'. SPE scripts require at least 'RemoteSigned' to run." -ForegroundColor Yellow + $consent = Read-Host "Allow 'RemoteSigned' for this user scope? (Y/N)" + if ($consent -eq 'Y') { + Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force + Write-Host "ExecutionPolicy set to 'RemoteSigned' for CurrentUser." -ForegroundColor Green + } else { + Write-Host "Cannot proceed with 'Restricted' execution policy. Exiting." -ForegroundColor Red + exit 1 + } + } +} catch { + # Get-ExecutionPolicy may not be available in all PS environments (e.g., PS Core without Security module) + # Safe to proceed - if scripts couldn't run, we wouldn't be here +} + +# Known permission GUIDs (stable, from Microsoft Graph service principal manifest) +$PERMS = @{ + FileStorageContainer_Selected_Delegated = "085ca537-6565-41c2-aca7-db852babc212" + FileStorageContainerType_ManageAll_Delegated = "8e6ec84c-5fcd-4cc7-ac8a-2296efc0ed9b" + FileStorageContainerTypeReg_ManageAll_Delegated = "c319a7df-930e-44c0-a43b-7e5e9c7f4f24" +} + +function Invoke-GraphRequest { + param( + [string]$Uri, + [string]$Method = "GET", + [hashtable]$Headers, + [string]$Body = $null, + [int]$MaxRetries = 3 + ) + $params = @{ + Uri = $Uri + Method = $Method + Headers = $Headers + } + if ($Body) { $params.Body = $Body } + + for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { + try { + return Invoke-RestMethod @params + } catch { + $status = 0 + try { $status = [int]$_.Exception.Response.StatusCode.value__ } catch {} + $detail = $_.ErrorDetails.Message + + if ($status -eq 429 -or ($status -ge 500 -and $status -lt 600)) { + if ($attempt -eq $MaxRetries) { + throw "Graph API error ($status) after $MaxRetries retries: $detail" + } + $retryAfter = 5 + try { + $retryHeader = $_.Exception.Response.Headers | Where-Object { $_.Key -eq 'Retry-After' } | Select-Object -ExpandProperty Value -First 1 + if ($retryHeader) { $retryAfter = [Math]::Max([int]$retryHeader, 1) } + } catch {} + $waitSeconds = $retryAfter * $attempt + Write-Host " Throttled/transient ($status). Retrying in ${waitSeconds}s..." -ForegroundColor Yellow + Start-Sleep -Seconds $waitSeconds + continue + } + + throw "Graph API error ($status): $detail" + } + } +} + +function Read-EnvFile { + $path = Join-Path (Get-Location) ".env.spe" + $result = [ordered]@{} + if (Test-Path $path) { + Get-Content $path | ForEach-Object { + if ($_ -match '^([A-Z_]+)=(.+)$') { + $result[$Matches[1]] = $Matches[2] + } + } + } + return $result +} + +function Save-EnvFile { + param($Values) + $path = Join-Path (Get-Location) ".env.spe" + $lines = @( + "# SharePoint Embedded configuration" + "# Updated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" + ) + foreach ($key in $Values.Keys) { + $lines += "$key=$($Values[$key])" + } + $content = $lines -join "`n" + [System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false)) +} + +function Get-BootstrapHeaders { + $token = az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv + if (-not $token) { throw "Failed to get Graph token. Run 01-auth.ps1 first." } + return @{ + Authorization = "Bearer $token" + "Content-Type" = "application/json" + } +} + +function Get-SpeHeaders { + $tokenPath = Join-Path (Get-Location) ".spe-token" + if (-not (Test-Path $tokenPath)) { + throw "No SPE token found. Run 03-token.ps1 first." + } + $token = (Get-Content $tokenPath -Raw).Trim() + if (-not $token) { throw "SPE token file is empty. Run 03-token.ps1 first." } + + # Check JWT expiry + $payload = $token.Split('.')[1] + switch ($payload.Length % 4) { + 2 { $payload += '==' } + 3 { $payload += '=' } + } + $decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json + $expiry = [DateTimeOffset]::FromUnixTimeSeconds($decoded.exp).LocalDateTime + if ($expiry -le (Get-Date).AddMinutes(2)) { + throw "SPE token expired at $($expiry.ToString('HH:mm:ss')). Run 03-token.ps1 again." + } + + return @{ + Authorization = "Bearer $token" + "Content-Type" = "application/json" + } +} + +function Assert-EnvKeys { + param([string[]]$Keys) + $env = Read-EnvFile + foreach ($key in $Keys) { + if (-not $env[$key]) { + $stepHint = switch ($key) { + "TENANT_ID" { "01-auth.ps1" } + "CLIENT_ID" { "02-app.ps1" } + "APP_OBJECT_ID" { "02-app.ps1" } + "CONTAINER_TYPE_ID" { "04-container-type.ps1" } + "CONTAINER_ID" { "05-container.ps1" } + default { "a previous step" } + } + throw "Missing $key in .env.spe. Run $stepHint first." + } + } + return $env +} diff --git a/AI/skills/full-setup/gotchas.md b/AI/skills/full-setup/gotchas.md new file mode 100644 index 0000000..6d4e680 --- /dev/null +++ b/AI/skills/full-setup/gotchas.md @@ -0,0 +1,15 @@ +# SPE Setup — Gotchas and Edge Cases + +Reference material from live testing. The scripts handle all of these automatically. + +1. **`az login` needs `--allow-no-subscriptions`** — M365-only tenants have no Azure subscriptions. +2. **Graph API field is `name`, NOT `displayName`** — For container type creation. +3. **Response field is `id`, NOT `containerTypeId`** — Container type ID comes back as `id`. +4. **Permission GUIDs are critical** — `085ca537...`, `8e6ec84c...`, `c319a7df...` (in `_common.ps1`). +5. **Registration MUST include `applicationPermissionGrants`** — Without it, container creation fails with `UnauthorizedAccessException`. +6. **Each owning app gets ONE container type** — Scripts check before creating. +7. **Trial vs Standard billing** — `trial` works without billing policy. `standard` needs tenant config. +8. **New containers start `inactive`** — Must call `/activate`. +9. **Registration propagation delay (10-30s)** — `accessDenied` after registration is normal. Scripts retry with backoff. +10. **Service principal auto-created** — Device code sign-in creates it automatically. +11. **Trial CT limit is 3 per tenant** — Delete old ones or use `06-cleanup.ps1`. diff --git a/AI/skills/full-setup/spe-setup.ps1 b/AI/skills/full-setup/spe-setup.ps1 new file mode 100644 index 0000000..b13b8f2 --- /dev/null +++ b/AI/skills/full-setup/spe-setup.ps1 @@ -0,0 +1,76 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + SharePoint Embedded - Full setup (runs all steps in sequence). +.DESCRIPTION + Wrapper that runs 01-auth through 05-container in order. + For agent-driven setups, run each numbered script individually instead. +.PARAMETER AppDisplayName + Display name for the Entra app. Default: "My SPE App" +.PARAMETER ContainerTypeName + Name for the container type. Default: "My Container Type" +.PARAMETER ContainerName + Display name for the container. Default: "My First Container" +.PARAMETER BillingClassification + "trial" (default) or "standard". +.PARAMETER SkipCleanup + Skip the cleanup prompt at the end. +.PARAMETER NonInteractive + Skip pauses between steps (for fleet/eval runs). +.EXAMPLE + .\spe-setup.ps1 +.EXAMPLE + .\spe-setup.ps1 -AppDisplayName "Contoso Legal" -NonInteractive -SkipCleanup +#> + +param( + [string]$AppDisplayName = "My SPE App", + [string]$ContainerTypeName = "My Container Type", + [string]$ContainerName = "My First Container", + [string]$BillingClassification = "trial", + [switch]$SkipCleanup, + [switch]$NonInteractive +) + +$ErrorActionPreference = "Stop" +$scriptDir = $PSScriptRoot + +function Pause-Step { + param([string]$NextStep) + if (-not $NonInteractive) { + Write-Host "" + Read-Host " Press Enter to continue to $NextStep" + } +} + +# Step 1: Azure CLI auth +& "$scriptDir\01-auth.ps1" +Pause-Step "Entra App Registration" + +# Step 2-3: Create app + permissions +& "$scriptDir\02-app.ps1" -AppDisplayName $AppDisplayName +Pause-Step "Device Code Authentication" + +# Step 4: SPE token via device code +& "$scriptDir\03-token.ps1" +Pause-Step "Container Type" + +# Step 5-6: Container type + registration +& "$scriptDir\04-container-type.ps1" -ContainerTypeName $ContainerTypeName -BillingClassification $BillingClassification +Pause-Step "Container + Proof File" + +# Step 7-8: Container + upload + preview +& "$scriptDir\05-container.ps1" -ContainerName $ContainerName + +# Step 9: Cleanup (optional) +if (-not $SkipCleanup) { + Write-Host "" + $cleanup = Read-Host " Clean up resources? Deletes container type + app (y/n)" + if ($cleanup -eq 'y') { + & "$scriptDir\06-cleanup.ps1" + } else { + Write-Host " Skipping cleanup. Resources preserved." -ForegroundColor Gray + } +} else { + Write-Host " Cleanup skipped (-SkipCleanup)." -ForegroundColor Gray +} diff --git a/AI/skills/reference/auth.md b/AI/skills/reference/auth.md new file mode 100644 index 0000000..13a22b5 --- /dev/null +++ b/AI/skills/reference/auth.md @@ -0,0 +1,146 @@ +# Auth Patterns for SharePoint Embedded Skills + +## Overview + +The full-setup skill uses a two-moment auth pattern. This document explains the details and fallback options. + +--- + +## Moment 1: Bootstrap Token (Azure CLI) + +Used for Entra app creation and permission configuration only. + +```powershell +# Login (one-time, interactive) +az login --allow-no-subscriptions + +# Get token (non-interactive, can be called repeatedly) +$token = az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv +``` + +**Why `--allow-no-subscriptions`:** M365-only tenants don't have Azure subscriptions. Without this flag, `az login` reports "No subscriptions found" and exits with error code 1. + +**Scopes available:** The Azure CLI token has whatever permissions the signed-in user has in the tenant. For app creation, the user needs `Application.ReadWrite.All` (typically Global Admin or Application Admin). + +--- + +## Moment 2: SPE Token (Interactive Login) + +Used for all SPE operations (container types, containers, permissions). + +The script (`03-token.ps1`) uses a two-tier auth strategy: + +1. **Primary: Interactive browser login (auth code + PKCE)** — Opens a browser, user signs in, callback on `http://localhost:3000` +2. **Fallback: Device code flow** — If browser can't open, user visits a URL and enters a code + +### Interactive Flow (Primary) + +```powershell +# 1. Generate PKCE code verifier + challenge +# 2. Start HttpListener on localhost:3000 +# 3. Open browser to authorization URL +# 4. User signs in and consents +# 5. Browser redirects to localhost:3000 with auth code +# 6. Exchange auth code + code_verifier for token +``` + +This is the same pattern `az login` uses — smoother UX, no manual code entry. + +### Device Code Flow (Fallback) + +Used when browser cannot be launched (headless environments, agent-driven terminals): + +```powershell +# Force device code flow explicitly: +powershell -File 03-token.ps1 -UseDeviceCode +``` + +### Auth Method Comparison + +| Flow | Browser Opens | User Action | Security | When Used | +|------|--------------|-------------|----------|-----------| +| Auth Code + PKCE | Yes (auto) | Sign in, close tab | PKCE + state + CSRF | Default (interactive) | +| Device Code | No | Visit URL, enter code | Phishing risk (mitigated) | Fallback / `-UseDeviceCode` | +| Client Credentials | N/A | None | No user context | Not supported (need delegated) | + +> **Security note:** The interactive flow uses PKCE (Proof Key for Code Exchange) and state validation to prevent CSRF and auth code interception attacks. Device code flow is only used as a fallback and carries a theoretical phishing risk, mitigated by the fact that the user initiates the flow themselves from their own terminal. + +> **Public client requirement:** Device code flow only works with public client applications (`isFallbackPublicClient: true`). If a user provides a bring-your-own app ID, the `03-token.ps1` script validates this before attempting the flow. Confidential client applications will be rejected with a clear error message. + +--- + +## Consent Behavior + +### First Run (No Prior Consent) + +When the user completes the device code flow for the first time with this app, they see a consent prompt: + +``` +Permissions requested: +☑ Read and write items in selected file storage containers +☑ Manage all file storage container types +☑ Manage all file storage container type registrations + +[Accept] [Cancel] +``` + +The user clicks **Accept** once. All subsequent runs skip the consent prompt. + +### Subsequent Runs + +- If the user has already consented, the device code flow completes without a consent prompt +- The token is issued immediately after login + +--- + +## Token Scopes in the Response + +After device code flow, verify the token has all required scopes: + +```powershell +# Decode JWT payload (base64) +$payload = $speToken.Split('.')[1] +$padding = 4 - ($payload.Length % 4) +if ($padding -ne 4) { $payload += '=' * $padding } +$decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json +Write-Host "Scopes: $($decoded.scp)" +``` + +Expected output: +``` +Scopes: FileStorageContainer.Selected FileStorageContainerType.Manage.All FileStorageContainerTypeReg.Manage.All +``` + +If any scope is missing, the app's `requiredResourceAccess` was not set correctly in Step 3. + +--- + +## Fallback: No Admin Access + +If the user cannot run `az login` with admin privileges (e.g., they're a regular user, or Azure CLI isn't installed): + +### Option A: Pre-existing App ID + +If someone (an admin) has already created the Entra app and shared the `CLIENT_ID`: + +```powershell +# Skip Steps 1-3 entirely. Start at Step 4 (device code flow). +$appId = "pre-existing-client-id" +$tenantId = "known-tenant-id" +# Continue with device code flow... +``` + +### Option B: Manual App Creation + Paste + +1. Agent instructs user: "Go to https://portal.azure.com → App registrations → New registration" +2. User creates app, adds permissions, and pastes the `CLIENT_ID` back to the agent +3. Agent continues from Step 4 + +### Option C: Paste a Token + +If the user can obtain a token through another means (Postman, Graph Explorer): + +```powershell +$speToken = Read-Host "Paste your Graph API token" +# Continue with SPE operations... +``` diff --git a/AI/skills/reference/graph-api-reference.md b/AI/skills/reference/graph-api-reference.md new file mode 100644 index 0000000..d29dc61 --- /dev/null +++ b/AI/skills/reference/graph-api-reference.md @@ -0,0 +1,252 @@ +# Graph API Reference for SharePoint Embedded + +## API Stability + +Scripts use v1.0 endpoints where available. Beta-only endpoints are documented below. + +| Endpoint Category | API Version | Notes | +|-------------------|-------------|-------| +| Container Types CRUD | **v1.0** | Stable | +| Container Type Registrations | **v1.0** | Stable | +| Containers CRUD (create, list, get, delete, activate) | **v1.0** | Stable | +| Container Permissions | **beta** | No v1.0 equivalent — required for permission management | +| Container Lock/Unlock | **beta** | No v1.0 equivalent — required for archive/restore | +| Deleted Containers (list, restore, purge) | **beta** | No v1.0 equivalent | +| Custom Properties | **v1.0** | Stable | +| Container Drive | **v1.0** | Stable | +| Drive/DriveItem Operations | **v1.0** | Stable | +| App Registration | **v1.0** | Stable | + +## Contents + +- [Container Types](#container-types) +- [Container Type Registrations](#container-type-registrations) +- [Containers](#containers) +- [Container Permissions](#container-permissions) +- [Custom Properties](#custom-properties) +- [Container Drive](#container-drive) +- [Deleted Containers](#deleted-containers) +- [Common Errors](#common-errors) + +--- + +## Container Types + +| Operation | Method | Endpoint | Auth Scope | +|-----------|--------|----------|------------| +| Create | `POST` | `/containerTypes` | `FileStorageContainerType.Manage.All` | +| List | `GET` | `/containerTypes` | `FileStorageContainerType.Manage.All` | +| Get | `GET` | `/containerTypes/{id}` | `FileStorageContainerType.Manage.All` | +| Update | `PATCH` | `/containerTypes/{id}` | `FileStorageContainerType.Manage.All` | +| Delete | `DELETE` | `/containerTypes/{id}` | `FileStorageContainerType.Manage.All` | + +### Create Container Type + +```http +POST https://graph.microsoft.com/beta/storage/fileStorage/containerTypes +Content-Type: application/json +Authorization: Bearer {token} + +{ + "name": "My Container Type", + "owningAppId": "app-id-guid", + "billingClassification": "trial" +} +``` + +> **GOTCHA:** The field is `name`, NOT `displayName`. Using `displayName` returns an error. + +> **GOTCHA:** The response returns the container type ID as `id`, NOT `containerTypeId`. + +### Optional Settings (Create or Update) + +```json +{ + "name": "My Container Type", + "owningAppId": "app-id-guid", + "billingClassification": "trial", + "settings": { + "isDiscoverabilityEnabled": false, + "isItemVersioningEnabled": true, + "isSearchEnabled": true, + "isSharingRestricted": false, + "itemMajorVersionLimit": 500, + "maxStoragePerContainerInBytes": 27487790694400, + "sharingCapability": "disabled", + "urlTemplate": "myapp/{containerName}", + "consumingTenantOverridables": "isSearchEnabled,itemMajorVersionLimit", + "agent": { + "chatEmbedAllowedHosts": ["https://myapp.com"] + } + } +} +``` + +--- + +## Container Type Registrations + +| Operation | Method | Endpoint | Auth Scope | +|-----------|--------|----------|------------| +| Register (create/replace) | `PUT` | `/containerTypeRegistrations/{containerTypeId}` | `FileStorageContainerTypeReg.Manage.All` | +| List | `GET` | `/containerTypeRegistrations` | `FileStorageContainerTypeReg.Manage.All` | +| Get | `GET` | `/containerTypeRegistrations/{containerTypeId}` | `FileStorageContainerTypeReg.Manage.All` | +| Delete | `DELETE` | `/containerTypeRegistrations/{containerTypeId}` | `FileStorageContainerTypeReg.Manage.All` | + +### Register Container Type + +```http +PUT https://graph.microsoft.com/beta/storage/fileStorage/containerTypeRegistrations/{containerTypeId} +Content-Type: application/json +Authorization: Bearer {token} + +{ + "applicationPermissionGrants": [ + { + "appId": "your-app-id", + "delegatedPermissions": ["full"], + "applicationPermissions": ["full"] + } + ] +} +``` + +> **CRITICAL:** If `applicationPermissionGrants` is empty or omitted, container creation will fail with `UnauthorizedAccessException`. The consuming tenant store will have no permissions record for the app. + +--- + +## Containers + +| Operation | Method | Endpoint | Auth Scope | +|-----------|--------|----------|------------| +| Create | `POST` | `/containers` | `FileStorageContainer.Selected` | +| List | `GET` | `/containers?$filter=containerTypeId eq '{id}'` | `FileStorageContainer.Selected` | +| Get | `GET` | `/containers/{containerId}` | `FileStorageContainer.Selected` | +| Update | `PATCH` | `/containers/{containerId}` | `FileStorageContainer.Selected` | +| Delete | `DELETE` | `/containers/{containerId}` | `FileStorageContainer.Selected` | +| Activate | `POST` | `/containers/{containerId}/activate` | `FileStorageContainer.Selected` | +| Lock | `POST` | `/containers/{containerId}/lock` | `FileStorageContainer.Selected` | +| Unlock | `POST` | `/containers/{containerId}/unlock` | `FileStorageContainer.Selected` | +| Permanent Delete | `POST` | `/containers/{containerId}/permanentDelete` | `FileStorageContainer.Selected` | + +### Create Container + +```http +POST https://graph.microsoft.com/beta/storage/fileStorage/containers +Content-Type: application/json +Authorization: Bearer {token} + +{ + "containerTypeId": "container-type-id", + "displayName": "My Container", + "description": "Optional description" +} +``` + +> **GOTCHA:** New containers start as `inactive`. You must call `/activate` to make them operational. + +### Optional Container Settings + +```json +{ + "containerTypeId": "container-type-id", + "displayName": "My Container", + "settings": { + "isOcrEnabled": true, + "isItemVersioningEnabled": true, + "itemMajorVersionLimit": 50, + "itemDefaultSensitivityLabelId": "label-guid" + } +} +``` + +--- + +## Container Permissions + +| Operation | Method | Endpoint | Auth Scope | +|-----------|--------|----------|------------| +| Create | `POST` | `/containers/{id}/permissions` | `FileStorageContainer.Selected` | +| List | `GET` | `/containers/{id}/permissions` | `FileStorageContainer.Selected` | +| Get | `GET` | `/containers/{id}/permissions/{permId}` | `FileStorageContainer.Selected` | +| Update | `PATCH` | `/containers/{id}/permissions/{permId}` | `FileStorageContainer.Selected` | +| Delete | `DELETE` | `/containers/{id}/permissions/{permId}` | `FileStorageContainer.Selected` | + +### Create Permission + +```http +POST https://graph.microsoft.com/beta/storage/fileStorage/containers/{containerId}/permissions +Content-Type: application/json +Authorization: Bearer {token} + +{ + "roles": ["writer"], + "grantedToV2": { + "user": { + "userPrincipalName": "user@contoso.com" + } + } +} +``` + +Valid roles: `reader`, `writer`, `manager`, `owner` + +> **GOTCHA:** The field is `userPrincipalName`, NOT `email`. + +--- + +## Custom Properties + +| Operation | Method | Endpoint | +|-----------|--------|----------| +| Get | `GET` | `/containers/{id}/customProperties` | +| Set | `PATCH` | `/containers/{id}/customProperties` | + +### Set Custom Properties + +```http +PATCH https://graph.microsoft.com/beta/storage/fileStorage/containers/{containerId}/customProperties +Content-Type: application/json + +{ + "myProperty": { + "value": "hello", + "isSearchable": true + } +} +``` + +Set `value` to `null` to delete a property. + +--- + +## Container Drive + +| Operation | Method | Endpoint | +|-----------|--------|----------| +| Get Drive | `GET` | `/containers/{id}/drive` | + +Returns the OneDrive drive (document library) associated with the container. Use standard Drive/DriveItem APIs for file operations. + +--- + +## Deleted Containers + +| Operation | Method | Endpoint | +|-----------|--------|----------| +| List | `GET` | `/deletedContainers` | +| Get | `GET` | `/deletedContainers/{id}` | +| Restore | `POST` | `/deletedContainers/{id}/restore` | +| Purge | `DELETE` | `/deletedContainers/{id}` | + +--- + +## Common Errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `UnauthorizedAccessException` on container creation | Registration has no `applicationPermissionGrants` | Re-register with PUT including the `applicationPermissionGrants` array | +| "Container type must have a name, owning app id and container type id" | Used `displayName` instead of `name` in CT creation | Use `name` field | +| `403 Forbidden` | Token missing required scope | Check `scp` claim in JWT; re-declare permissions on app | +| "Billing policy not found" | Used `billingClassification: standard` on dev tenant | Use `trial` for development | +| Each owning app can only have one container type | App already has a CT | Check existing CTs before creating |