Your calm window into the Atmosphere. morgen.blue
rss atproto
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(dev): wire Cloudflare named tunnel for granular OAuth dev scopes

The Bluesky package clamps scopes to atproto when client_id falls back
to http://localhost in dev (loopback profile). Front local dev with a
public Cloudflare named tunnel so the AS can fetch the real client
metadata document, which declares the granular repo:app.skyreader.*
scopes — and the consent screen lists them.

- AppServiceProvider: force HTTPS scheme so url() matches APP_URL
behind a TLS-terminating proxy
- config/solo.php: add Tunnel command; pass --no-reload to serve
so PHP_CLI_SERVER_WORKERS=4 actually spawns workers (the AS's
synchronous metadata fetch during PAR otherwise deadlocks)
- README: per-dev cloudflared setup, parameterized with <subdomain>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+88 -3
+79
README.md
··· 1 + # Morgenblau 2 + 3 + A calm content platform powered by RSS and ATProto. See [SPEC.md](./SPEC.md) for the product vision and [CLAUDE.md](./CLAUDE.md) for stack/conventions. 4 + 5 + ## Local development 6 + 7 + Standard Laravel + Vite setup. With dependencies installed (`composer install && bun install`) and a populated `.env`, run: 8 + 9 + ```sh 10 + php artisan solo 11 + ``` 12 + 13 + …which starts `Serve` (PHP on `:8000`), `Tunnel` (Cloudflare named tunnel), `Vite` (bun dev), and `Queue`. 14 + 15 + ### Dev OAuth tunnel — one-time setup 16 + 17 + ATProto OAuth requires Bluesky's authorization server to fetch your `/oauth-client-metadata.json` over public HTTPS. Without that, the consent screen falls back to identity-only and the app can't request granular `repo:app.skyreader.*` scopes. We expose `127.0.0.1:8000` to the internet via a Cloudflare named tunnel pinned to `<subdomain>.morgen.blue`. 18 + 19 + 1. **Install cloudflared:** 20 + 21 + ```sh 22 + brew install cloudflared 23 + ``` 24 + 25 + 2. **Authenticate with Cloudflare** (opens a browser; pick the `morgen.blue` zone): 26 + 27 + ```sh 28 + cloudflared tunnel login 29 + ``` 30 + 31 + 3. **Create a named tunnel** (writes credentials to `~/.cloudflared/<UUID>.json`): 32 + 33 + ```sh 34 + cloudflared tunnel create morgenblau-<subdomain> 35 + ``` 36 + 37 + 4. **Route the subdomain** (creates a `CNAME` in Cloudflare DNS, stable forever): 38 + 39 + ```sh 40 + cloudflared tunnel route dns morgenblau-<subdomain> <subdomain>.morgen.blue 41 + ``` 42 + 43 + 5. **Write `~/.cloudflared/config.yml`** (replace `<UUID>` with the value from step 3): 44 + 45 + ```yaml 46 + tunnel: morgenblau-<subdomain> 47 + credentials-file: /Users/<your-mac-user>/.cloudflared/<UUID>.json 48 + 49 + ingress: 50 + - hostname: <subdomain>.morgen.blue 51 + service: http://localhost:8000 52 + - service: http_status:404 53 + ``` 54 + 55 + 6. **Set `.env`**: 56 + 57 + ```dotenv 58 + APP_URL=https://<subdomain>.morgen.blue 59 + BLUESKY_CLIENT_ID=https://<subdomain>.morgen.blue/oauth-client-metadata.json 60 + BLUESKY_REDIRECT=https://<subdomain>.morgen.blue/oauth/callback 61 + 62 + # Required: the AS validates client_id by fetching /oauth-client-metadata.json 63 + # synchronously during PAR. With the default 1-worker `php artisan serve`, 64 + # the PAR request and the AS's metadata fetch deadlock and PHP times out at 30s. 65 + PHP_CLI_SERVER_WORKERS=4 66 + ``` 67 + 68 + 7. **Run** `php artisan solo`. The `Tunnel` process picks up `~/.cloudflared/config.yml` and serves the subdomain. Visit `https://<subdomain>.morgen.blue/login` to test the OAuth flow. 69 + 70 + The `Tunnel` command in `config/solo.php` deliberately takes no arguments — it reads `~/.cloudflared/config.yml`, which is per-machine. The committed `solo.php` stays dev-agnostic. 71 + 72 + ### Verifying the OAuth metadata document 73 + 74 + ```sh 75 + curl -s https://<subdomain>.morgen.blue/oauth-client-metadata.json | jq 76 + curl -s https://<subdomain>.morgen.blue/oauth-jwks.json | jq '.keys[0] | keys' 77 + ``` 78 + 79 + The metadata `scope` should list all four `repo:app.skyreader.*` collections. The JWKS must not expose a `d` field (that would be a private-key leak).
+6
app/Providers/AppServiceProvider.php
··· 8 8 use Illuminate\Support\Facades\Date; 9 9 use Illuminate\Support\Facades\DB; 10 10 use Illuminate\Support\Facades\Event; 11 + use Illuminate\Support\Facades\URL; 11 12 use Illuminate\Support\ServiceProvider; 13 + use Illuminate\Support\Str; 12 14 use Revolution\Bluesky\Events\OAuthSessionRefreshing; 13 15 use Revolution\Bluesky\Events\OAuthSessionUpdated; 14 16 use Revolution\Bluesky\Socialite\OAuthConfig; ··· 42 44 DB::prohibitDestructiveCommands( 43 45 app()->isProduction(), 44 46 ); 47 + 48 + if (Str::startsWith((string) config('app.url'), 'https://')) { 49 + URL::forceScheme('https'); 50 + } 45 51 } 46 52 47 53 /**
+3 -3
config/solo.php
··· 47 47 */ 48 48 'commands' => [ 49 49 // 'About' => 'php artisan solo:about', 50 - // Port 8000 is required: the revolution/laravel-bluesky package hardcodes 51 - // http://127.0.0.1:8000/ as the OAuth loopback redirect URI in dev. 52 - 'Serve' => 'php artisan serve --port=8000', 50 + // --no-reload is required for PHP_CLI_SERVER_WORKERS to take effect 51 + 'Serve' => 'php artisan serve --port=8000 --no-reload', 52 + 'Tunnel' => 'cloudflared tunnel run', 53 53 'Vite' => 'bun run dev', 54 54 // For enhanced log viewing with vendor frame collapsing, see soloterm/vtail 55 55 'Logs' => 'tail -f -n 100 '.storage_path('logs/laravel.log'),