feat(dev): add one-command local Stalwart test server + workflow docs
Add docker-compose.yml: a disposable Stalwart instance with a fixed dev admin account (STALWART_RECOVERY_ADMIN), matching the credentials scripts/dev-token.ps1 already expected. Wired up via new npm scripts (dev:server, dev:server:down, dev:server:logs). scripts/dev-token.ps1 was previously untracked (the whole scripts/ directory was gitignored) even though it's part of the documented dev workflow — un-ignored it, and added scripts/dev-token.sh, a POSIX equivalent for non-Windows shells and AI agents without PowerShell. New DEVELOPMENT.md documents the full loop end-to-end (start server, get a token, run the dev server, verify), written so it's actionable by both humans and AI coding agents without needing a browser. Linked from AGENTS.md (Commands) and README.md (Getting started). Verified manually: docker compose up brings the server to a healthy state, /api/auth + /auth/token issue a working bearer token, and /jmap/session returns 200 with it end-to-end.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generates a fresh OAuth access token from the local Stalwart dev container
|
||||
and writes it to .env.development.local (gitignored).
|
||||
|
||||
.DESCRIPTION
|
||||
Local development only. Requires the dev container from docker-compose.yml
|
||||
(`docker compose up -d`) to be running. Tokens expire after 1 hour; re-run
|
||||
this script and restart "npm run dev" when the UI starts returning 401s.
|
||||
The credentials below belong to the disposable local Stalwart container.
|
||||
See DEVELOPMENT.md for the full workflow. Non-Windows shells (and AI
|
||||
agents without PowerShell) can use scripts/dev-token.sh instead.
|
||||
#>
|
||||
param(
|
||||
[string]$ApiBaseUrl = "http://localhost:8080",
|
||||
[string]$AccountName = "admin@example.org",
|
||||
[string]$AccountSecret = "c8321iEscHDy0GWV"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
# PKCE pair (S256)
|
||||
$chars = (48..57) + (65..90) + (97..122)
|
||||
$verifier = -join ($chars | Get-Random -Count 64 | ForEach-Object { [char]$_ })
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
$challenge = [Convert]::ToBase64String($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($verifier))).Replace('+', '-').Replace('/', '_').TrimEnd('=')
|
||||
|
||||
$redirectUri = "http://localhost:3005/oauth/callback"
|
||||
|
||||
$authPayload = @{
|
||||
type = "authCode"
|
||||
accountName = $AccountName
|
||||
accountSecret = $AccountSecret
|
||||
clientId = "stalwart-webui"
|
||||
redirectUri = $redirectUri
|
||||
scope = "openid email profile offline_access"
|
||||
state = [guid]::NewGuid().ToString("N")
|
||||
codeChallenge = $challenge
|
||||
codeChallengeMethod = "S256"
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
$auth = Invoke-RestMethod -Uri "$ApiBaseUrl/api/auth" -Method Post -ContentType "application/json" -Body $authPayload -TimeoutSec 15
|
||||
if ($auth.type -ne "authenticated" -or -not $auth.client_code) {
|
||||
throw "Unexpected /api/auth response: $($auth | ConvertTo-Json -Compress)"
|
||||
}
|
||||
|
||||
$tokenBody = "grant_type=authorization_code&code=$($auth.client_code)&code_verifier=$verifier&client_id=stalwart-webui&redirect_uri=$([uri]::EscapeDataString($redirectUri))"
|
||||
$token = Invoke-RestMethod -Uri "$ApiBaseUrl/auth/token" -Method Post -ContentType "application/x-www-form-urlencoded" -Body $tokenBody -TimeoutSec 15
|
||||
|
||||
$envPath = Join-Path $root ".env.development.local"
|
||||
@"
|
||||
# Generated by scripts/dev-token.ps1 - gitignored, do not commit.
|
||||
# Empty base URL: API calls stay same-origin and go through the Vite proxy.
|
||||
VITE_API_BASE_URL=
|
||||
VITE_ACCESS_TOKEN=$($token.access_token)
|
||||
"@ | Set-Content -Path $envPath -Encoding ascii
|
||||
|
||||
Write-Host "Token written to $envPath (expires in $($token.expires_in)s). Restart 'npm run dev' to pick it up."
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local development only. Generates a fresh OAuth access token from the local
|
||||
# Stalwart dev container (see docker-compose.yml) and writes it to
|
||||
# .env.development.local (gitignored). Bash equivalent of dev-token.ps1, for
|
||||
# non-Windows shells (and AI agents without PowerShell).
|
||||
#
|
||||
# Tokens expire after 1 hour; re-run this script and restart "npm run dev"
|
||||
# when the UI starts returning 401s.
|
||||
# The credentials below belong to the disposable local Stalwart container.
|
||||
set -euo pipefail
|
||||
|
||||
API_BASE_URL="${1:-http://localhost:8080}"
|
||||
ACCOUNT_NAME="${2:-admin@example.org}"
|
||||
ACCOUNT_SECRET="${3:-c8321iEscHDy0GWV}"
|
||||
REDIRECT_URI="http://localhost:3005/oauth/callback"
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
b64url() {
|
||||
base64 | tr '+/' '-_' | tr -d '=\n'
|
||||
}
|
||||
|
||||
VERIFIER="$(head -c 48 /dev/urandom | b64url | head -c 64)"
|
||||
CHALLENGE="$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | b64url)"
|
||||
STATE="$(head -c 16 /dev/urandom | xxd -p)"
|
||||
|
||||
AUTH_PAYLOAD=$(cat <<JSON
|
||||
{"type":"authCode","accountName":"$ACCOUNT_NAME","accountSecret":"$ACCOUNT_SECRET","clientId":"stalwart-webui","redirectUri":"$REDIRECT_URI","scope":"openid email profile offline_access","state":"$STATE","codeChallenge":"$CHALLENGE","codeChallengeMethod":"S256"}
|
||||
JSON
|
||||
)
|
||||
|
||||
AUTH_RESPONSE=$(curl -sf "$API_BASE_URL/api/auth" -X POST -H "Content-Type: application/json" -d "$AUTH_PAYLOAD")
|
||||
CLIENT_CODE=$(printf '%s' "$AUTH_RESPONSE" | grep -o '"client_code":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$CLIENT_CODE" ]; then
|
||||
echo "Unexpected /api/auth response: $AUTH_RESPONSE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN_RESPONSE=$(curl -sf "$API_BASE_URL/auth/token" -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "grant_type=authorization_code" \
|
||||
--data-urlencode "code=$CLIENT_CODE" \
|
||||
--data-urlencode "code_verifier=$VERIFIER" \
|
||||
--data-urlencode "client_id=stalwart-webui" \
|
||||
--data-urlencode "redirect_uri=$REDIRECT_URI")
|
||||
|
||||
ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
|
||||
EXPIRES_IN=$(printf '%s' "$TOKEN_RESPONSE" | grep -o '"expires_in":[0-9]*' | cut -d':' -f2)
|
||||
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo "Unexpected /auth/token response: $TOKEN_RESPONSE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ENV_PATH="$ROOT_DIR/.env.development.local"
|
||||
cat > "$ENV_PATH" <<EOF
|
||||
# Generated by scripts/dev-token.sh - gitignored, do not commit.
|
||||
# Empty base URL: API calls stay same-origin and go through the Vite proxy.
|
||||
VITE_API_BASE_URL=
|
||||
VITE_ACCESS_TOKEN=$ACCESS_TOKEN
|
||||
EOF
|
||||
|
||||
echo "Token written to $ENV_PATH (expires in ${EXPIRES_IN}s). Restart 'npm run dev' to pick it up."
|
||||
Reference in New Issue
Block a user