From cd22332b31da7d86468e841260cec992d0afa4fb Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Mon, 17 Aug 2026 18:45:24 -0700 Subject: [PATCH 1/5] New assetlinks.json generation workflow --- .github/workflows/signing-fingerprint.yml | 341 ++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 .github/workflows/signing-fingerprint.yml diff --git a/.github/workflows/signing-fingerprint.yml b/.github/workflows/signing-fingerprint.yml new file mode 100644 index 0000000000..9a72c9f90a --- /dev/null +++ b/.github/workflows/signing-fingerprint.yml @@ -0,0 +1,341 @@ +name: Print release signing certificate fingerprint + +# Emits the SHA-256 certificate fingerprint of the release signing key, plus a +# ready-to-deploy Digital Asset Links file for Android App Link verification. +# +# The keystore only exists inside CI: SigningKeyUtils.downloadSigningKey() +# base64-decodes IDE_SIGNING_KEY_BIN into build/signing/signing-key.jks. This +# workflow decodes the same secret directly, so no Gradle build is needed. +# +# A certificate fingerprint is public data - it is published in assetlinks.json. +# No private key material is printed. +# +# With deploy=true the file is written to the "well-known" R2 bucket at key +# .well-known/assetlinks.json. A Cloudflare Origin Rule maps +# https:///.well-known/assetlinks.json onto that bucket, so the object key +# has to match the request path exactly. The rule is configured out of band; CI +# only owns the object. + +on: + workflow_dispatch: + inputs: + hosts: + description: 'Hosts serving assetlinks.json (comma-separated). Drives the deploy checklist and, when deploy is enabled, which hosts are verified. The file itself is host-independent.' + required: false + default: 'appdevforall.org,www.appdevforall.org' + extra_fingerprints: + description: 'Additional SHA-256 fingerprints to include (comma-separated, colon-hex). Use for developer debug keys when testing App Links locally.' + required: false + default: '' + deploy: + description: 'Upload the generated file to R2 and verify it is served. Leave off to only produce the artifact.' + type: boolean + required: false + default: false + # Dispatch is only offered for workflows on the default branch, so run on push + # of this file to let it work from a feature branch before it reaches stage. + push: + paths: + - '.github/workflows/signing-fingerprint.yml' + +permissions: + contents: read + +jobs: + fingerprint: + name: Fingerprint release signing key + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + IDE_SIGNING_ALIAS: ${{ secrets.IDE_SIGNING_ALIAS }} + IDE_SIGNING_STORE_PASS: ${{ secrets.IDE_SIGNING_STORE_PASS }} + IDE_SIGNING_KEY_BIN: ${{ secrets.IDE_SIGNING_KEY_BIN }} + R2_BUCKET: well-known + R2_KEY: .well-known/assetlinks.json + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Materialize keystore from IDE_SIGNING_KEY_BIN + run: | + set -euo pipefail + + # The runner context is step-scoped, so derive these here rather than in + # the job-level env block, where ${{ runner.temp }} would be empty. + echo "KEYSTORE=$RUNNER_TEMP/signing-key.jks" >> "$GITHUB_ENV" + echo "ASSETLINKS=$RUNNER_TEMP/assetlinks.json" >> "$GITHUB_ENV" + KEYSTORE="$RUNNER_TEMP/signing-key.jks" + + for var in IDE_SIGNING_KEY_BIN IDE_SIGNING_ALIAS IDE_SIGNING_STORE_PASS; do + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is not set. Check the repository secrets." >&2 + exit 1 + fi + done + + # Keystore lives outside the workspace so it cannot be picked up by an + # artifact upload or a later checkout. + printf '%s' "$IDE_SIGNING_KEY_BIN" | base64 -d > "$KEYSTORE" + chmod 600 "$KEYSTORE" + + if [ ! -s "$KEYSTORE" ]; then + echo "ERROR: decoded keystore is empty - IDE_SIGNING_KEY_BIN is not valid base64." >&2 + exit 1 + fi + echo "Decoded keystore: $(stat -c %s "$KEYSTORE") bytes" + + - name: Extract SHA-256 fingerprint + id: fp + run: | + set -euo pipefail + + if ! keytool -list -v \ + -keystore "$KEYSTORE" \ + -storepass "$IDE_SIGNING_STORE_PASS" \ + -alias "$IDE_SIGNING_ALIAS" > "$RUNNER_TEMP/keytool.txt" 2>&1; then + echo "ERROR: keytool failed. Alias in IDE_SIGNING_ALIAS may not match the keystore." >&2 + sed 's/^/ /' "$RUNNER_TEMP/keytool.txt" >&2 + echo "Entries present in the keystore:" >&2 + keytool -list -keystore "$KEYSTORE" -storepass "$IDE_SIGNING_STORE_PASS" \ + | grep -E 'Entry|entry' >&2 || true + exit 1 + fi + + fingerprint=$(awk '/SHA256:/ { print $2; exit }' "$RUNNER_TEMP/keytool.txt") + if [ -z "$fingerprint" ]; then + echo "ERROR: no SHA256 line in keytool output." >&2 + exit 1 + fi + + # Cross-check via OpenSSL against the exported certificate. A wrong + # fingerprint fails App Link verification silently, so verify it twice. + openssl_fp=$(keytool -exportcert -rfc \ + -keystore "$KEYSTORE" \ + -storepass "$IDE_SIGNING_STORE_PASS" \ + -alias "$IDE_SIGNING_ALIAS" \ + | openssl x509 -noout -fingerprint -sha256 \ + | cut -d= -f2) + + if [ "$fingerprint" != "$openssl_fp" ]; then + echo "ERROR: keytool and openssl disagree:" >&2 + echo " keytool: $fingerprint" >&2 + echo " openssl: $openssl_fp" >&2 + exit 1 + fi + + echo "SHA-256: $fingerprint" + echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT" + + # Certificate identity, useful for confirming this is the key you expect. + grep -E '^(Owner|Issuer|Valid from):' "$RUNNER_TEMP/keytool.txt" || true + + - name: Resolve application ID + id: pkg + run: | + set -euo pipefail + config=composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/BuildConfig.kt + pkg=$(sed -n 's/.*PACKAGE_NAME[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$config" | head -1) + if [ -z "$pkg" ]; then + echo "ERROR: could not read PACKAGE_NAME from $config" >&2 + exit 1 + fi + echo "Application ID: $pkg" + echo "package=$pkg" >> "$GITHUB_OUTPUT" + + - name: Generate assetlinks.json + env: + PKG: ${{ steps.pkg.outputs.package }} + FINGERPRINT: ${{ steps.fp.outputs.fingerprint }} + EXTRA: ${{ inputs.extra_fingerprints }} + run: | + set -euo pipefail + + # Normalize to uppercase colon-separated hex, drop blanks and duplicates. + # Strip spaces/tabs/CR only - deleting newlines here would splice the + # fingerprints into one unmatchable string. + fingerprints=$(printf '%s,%s' "$FINGERPRINT" "$EXTRA" \ + | tr ',' '\n' \ + | tr -d ' \t\r' \ + | tr '[:lower:]' '[:upper:]' \ + | grep -E '^([0-9A-F]{2}:){31}[0-9A-F]{2}$' \ + | awk '!seen[$0]++' \ + | jq -R . | jq -s .) + + jq -n --arg pkg "$PKG" --argjson fps "$fingerprints" '[ + { + relation: ["delegate_permission/common.handle_all_urls"], + target: { + namespace: "android_app", + package_name: $pkg, + sha256_cert_fingerprints: $fps + } + } + ]' > "$ASSETLINKS" + + cat "$ASSETLINKS" + + - name: Validate assetlinks.json + env: + PKG: ${{ steps.pkg.outputs.package }} + run: | + set -euo pipefail + + # A malformed or mismatched file fails App Link verification silently on + # device, so assert the full shape here rather than discover it later. + jq -e 'type == "array" and length == 1' "$ASSETLINKS" > /dev/null \ + || { echo "ERROR: expected a single-entry array." >&2; exit 1; } + + jq -e --arg pkg "$PKG" ' + .[0] as $e + | ($e.relation | index("delegate_permission/common.handle_all_urls")) != null + and $e.target.namespace == "android_app" + and $e.target.package_name == $pkg + and ($e.target.sha256_cert_fingerprints | length) >= 1 + and ($e.target.sha256_cert_fingerprints + | all(test("^([0-9A-F]{2}:){31}[0-9A-F]{2}$"))) + ' "$ASSETLINKS" > /dev/null \ + || { echo "ERROR: entry does not describe $PKG with valid SHA-256 fingerprints." >&2; exit 1; } + + echo "Validated: $(jq -r '.[0].target.sha256_cert_fingerprints | length' "$ASSETLINKS") fingerprint(s) for $PKG" + + - name: Upload assetlinks.json + uses: actions/upload-artifact@v4 + with: + name: assetlinks + path: ${{ env.ASSETLINKS }} + if-no-files-found: error + + - name: Deploy assetlinks.json to R2 + if: github.event_name == 'workflow_dispatch' && inputs.deploy + env: + AWS_ACCESS_KEY_ID: ${{ vars.CLOUDFLARE_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + # AWS CLI v2 sends CRC32 integrity headers by default, which R2 rejects + # with "Header 'x-amz-checksum-algorithm' ... not implemented". + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + run: | + set -euo pipefail + + # The Cloudflare Origin Rule maps the request path straight onto the + # object key, so the key must stay ".well-known/assetlinks.json". + aws s3 cp "$ASSETLINKS" "s3://$R2_BUCKET/$R2_KEY" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --content-type application/json + + - name: Verify the deployed file is served + if: github.event_name == 'workflow_dispatch' && inputs.deploy + env: + HOSTS: ${{ inputs.hosts || 'appdevforall.org,www.appdevforall.org' }} + run: | + set -euo pipefail + + expected=$(jq -S -c . "$ASSETLINKS") + failed=0 + + for host in ${HOSTS//,/ }; do + url="https://$host/$R2_KEY" + hdr="$RUNNER_TEMP/hdr" body="$RUNNER_TEMP/body" + + # Cloudflare can take a moment to pick up a freshly written object. + served=0 + for _ in 1 2 3 4 5; do + if curl -fsS --max-time 20 -D "$hdr" -o "$body" "$url"; then + served=1 + break + fi + sleep 5 + done + + if [ "$served" -ne 1 ]; then + echo "FAIL $url did not return 200. The R2 upload succeeded, so the likely cause is a missing or misconfigured Cloudflare Origin Rule for this host." >&2 + failed=1 + continue + fi + + if [ "$(jq -S -c . < "$body")" != "$expected" ]; then + echo "FAIL $url served content that differs from what was uploaded (stale edge cache, or the rule points elsewhere)." >&2 + failed=1 + continue + fi + + ctype=$(tr -d '\r' < "$hdr" | awk -F': ' 'tolower($1) == "content-type" { print tolower($2) }' | tail -1) + case "$ctype" in + application/json*) + echo "OK $url ($ctype)" + ;; + *) + echo "FAIL $url served Content-Type '$ctype'; Digital Asset Links requires application/json." >&2 + failed=1 + ;; + esac + done + + exit "$failed" + + - name: Write job summary + env: + PKG: ${{ steps.pkg.outputs.package }} + FINGERPRINT: ${{ steps.fp.outputs.fingerprint }} + HOSTS: ${{ inputs.hosts || 'appdevforall.org,www.appdevforall.org' }} + DEPLOYED: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy }} + run: | + set -euo pipefail + { + echo "## Release signing certificate" + echo + echo "| | |" + echo "|---|---|" + echo "| Application ID | \`$PKG\` |" + echo "| SHA-256 | \`$FINGERPRINT\` |" + echo + echo "### assetlinks.json" + echo + echo '```json' + cat "$ASSETLINKS" + echo '```' + echo + echo "### Deploy" + echo + if [ "$DEPLOYED" = "true" ]; then + echo "Written to \`s3://$R2_BUCKET/$R2_KEY\` and confirmed served on:" + echo + for host in ${HOSTS//,/ }; do + echo "- \`https://$host/$R2_KEY\`" + done + else + echo "Not deployed. Re-run with **deploy** enabled, or publish the artifact by hand. The file is host-independent - the same bytes serve every host in the intent filter:" + echo + for host in ${HOSTS//,/ }; do + echo "- \`https://$host/$R2_KEY\` - HTTPS, \`Content-Type: application/json\`, no redirect" + done + fi + echo + echo "### Verify on device" + echo + echo '```bash' + for host in ${HOSTS//,/ }; do + echo "curl -sSI https://$host/$R2_KEY # expect 200, application/json, no 3xx" + done + echo "adb shell pm verify-app-links --re-verify $PKG" + echo "adb shell pm get-app-links $PKG" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Remove keystore + if: always() + run: | + # KEYSTORE may be unset if the decode step failed before exporting it. + ks="${KEYSTORE:-$RUNNER_TEMP/signing-key.jks}" + shred -u "$ks" 2>/dev/null || rm -f "$ks" + rm -f "$RUNNER_TEMP/keytool.txt" From 1065229806c1bdae6b790f0221e1bf836605af4c Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 18 Aug 2026 16:05:23 -0700 Subject: [PATCH 2/5] Serve assetlinks.json from R2 via a Worker, not an Origin Rule The Origin Rule approach the previous commit assumed cannot work on our Cloudflare Free plan: R2 selects a bucket from the Host header, and host header, SNI and DNS record overrides are all Enterprise-only. Free exposes only the destination-port override. A Worker replaces the origin fetch rather than retargeting it, and reaches the bucket through an R2 binding - an in-network handle, not a URL - so no DNS, TLS or Host header is involved and the bucket keeps public access off. - infra/well-known-worker: the Worker, wrangler.toml and a README covering the plan constraint, the bucket/token prerequisites and how to verify. - deploy-well-known-worker.yml: deploys it via cloudflare/wrangler-action. Needs a new CLOUDFLARE_WORKERS_DEPLOY_TOKEN secret; the existing CLOUDFLARE_KEY_ID / CLOUDFLARE_SECRET_ACCESS_KEY pair is an R2 S3 credential and cannot deploy a Worker. - signing-fingerprint.yml: comments and failure hints now name the Worker. No functional change - it already writes the key the Worker reads. Routes match exact paths rather than /.well-known/*, so certificate renewal via /.well-known/acme-challenge/ still reaches the origin, and a request with no matching object falls through to the origin as well. ADFA-5067 --- .../workflows/deploy-well-known-worker.yml | 85 +++++++++++++++++++ .github/workflows/signing-fingerprint.yml | 16 ++-- infra/well-known-worker/README.md | 58 +++++++++++++ infra/well-known-worker/src/index.js | 41 +++++++++ infra/well-known-worker/wrangler.toml | 22 +++++ 5 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/deploy-well-known-worker.yml create mode 100644 infra/well-known-worker/README.md create mode 100644 infra/well-known-worker/src/index.js create mode 100644 infra/well-known-worker/wrangler.toml diff --git a/.github/workflows/deploy-well-known-worker.yml b/.github/workflows/deploy-well-known-worker.yml new file mode 100644 index 0000000000..e9dadbf763 --- /dev/null +++ b/.github/workflows/deploy-well-known-worker.yml @@ -0,0 +1,85 @@ +name: Deploy well-known Worker + +# Deploys infra/well-known-worker, which serves +# https:///.well-known/assetlinks.json out of the private "well-known" R2 +# bucket. The object itself is written by signing-fingerprint.yml; this workflow +# owns only the code that reads it, and does not verify the served result - +# signing-fingerprint.yml already does that end to end when it deploys. +# +# A Cloudflare Origin Rule cannot do this job on the Free plan: host header, SNI +# and DNS record overrides are Enterprise-only, and R2 selects a bucket from the +# Host header. The Worker uses an R2 binding instead, so the bucket stays private. +# +# Requires a CLOUDFLARE_WORKERS_DEPLOY_TOKEN secret with: +# Account -> Workers Scripts -> Edit (upload the script, bind the bucket) +# Zone -> Workers Routes -> Edit (attach the routes on appdevforall.org) +# The existing CLOUDFLARE_KEY_ID / CLOUDFLARE_SECRET_ACCESS_KEY pair is an R2 +# S3-compatible credential and cannot deploy a Worker. + +on: + workflow_dispatch: + # Dispatch is only offered for workflows on the default branch, so run on push + # to let this work from a feature branch before it reaches stage. Note that a + # push on any branch therefore deploys the live Worker. + push: + paths: + - 'infra/well-known-worker/**' + - '.github/workflows/deploy-well-known-worker.yml' + +permissions: + contents: read + +# One deploy at a time: concurrent uploads of the same script race on the routes. +concurrency: + group: deploy-well-known-worker + cancel-in-progress: false + +jobs: + deploy: + name: Deploy Worker + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check Cloudflare credentials + env: + CLOUDFLARE_WORKERS_DEPLOY_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + # wrangler reports a missing token as an opaque auth error, so name the + # actual gap here. Required scopes are listed at the top of this file. + for var in CLOUDFLARE_WORKERS_DEPLOY_TOKEN CLOUDFLARE_ACCOUNT_ID; do + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is not set. See the header of this workflow." >&2 + exit 1 + fi + done + + - name: Deploy with Wrangler + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: infra/well-known-worker + wranglerVersion: '4.124.0' + command: deploy + + - name: Write job summary + run: | + set -euo pipefail + { + echo "## well-known Worker deployed" + echo + echo "Routes now served from the \`well-known\` R2 bucket:" + echo + echo "- \`https://appdevforall.org/.well-known/assetlinks.json\`" + echo "- \`https://www.appdevforall.org/.well-known/assetlinks.json\`" + echo + echo "A route with no matching object falls through to the site origin." + echo "Run **Print release signing certificate fingerprint** with \`deploy\` enabled to publish the object and verify it end to end." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/signing-fingerprint.yml b/.github/workflows/signing-fingerprint.yml index 9a72c9f90a..d78b65fc8c 100644 --- a/.github/workflows/signing-fingerprint.yml +++ b/.github/workflows/signing-fingerprint.yml @@ -11,10 +11,10 @@ name: Print release signing certificate fingerprint # No private key material is printed. # # With deploy=true the file is written to the "well-known" R2 bucket at key -# .well-known/assetlinks.json. A Cloudflare Origin Rule maps -# https:///.well-known/assetlinks.json onto that bucket, so the object key -# has to match the request path exactly. The rule is configured out of band; CI -# only owns the object. +# .well-known/assetlinks.json. The Worker in infra/well-known-worker serves that +# bucket at https:///.well-known/assetlinks.json, deriving the object key +# from the request path, so the key has to match the path exactly. The Worker is +# deployed by deploy-well-known-worker.yml; this workflow owns only the object. on: workflow_dispatch: @@ -227,8 +227,8 @@ jobs: run: | set -euo pipefail - # The Cloudflare Origin Rule maps the request path straight onto the - # object key, so the key must stay ".well-known/assetlinks.json". + # The Worker derives the object key from the request path, so the key + # must stay ".well-known/assetlinks.json". aws s3 cp "$ASSETLINKS" "s3://$R2_BUCKET/$R2_KEY" \ --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ --content-type application/json @@ -258,13 +258,13 @@ jobs: done if [ "$served" -ne 1 ]; then - echo "FAIL $url did not return 200. The R2 upload succeeded, so the likely cause is a missing or misconfigured Cloudflare Origin Rule for this host." >&2 + echo "FAIL $url did not return 200. The R2 upload succeeded, so the likely cause is the well-known Worker not being deployed or not routed for this host (see deploy-well-known-worker.yml)." >&2 failed=1 continue fi if [ "$(jq -S -c . < "$body")" != "$expected" ]; then - echo "FAIL $url served content that differs from what was uploaded (stale edge cache, or the rule points elsewhere)." >&2 + echo "FAIL $url served content that differs from what was uploaded (stale edge cache, or the Worker route points elsewhere)." >&2 failed=1 continue fi diff --git a/infra/well-known-worker/README.md b/infra/well-known-worker/README.md new file mode 100644 index 0000000000..fff90d5e3b --- /dev/null +++ b/infra/well-known-worker/README.md @@ -0,0 +1,58 @@ +# well-known Worker + +Serves `https://appdevforall.org/.well-known/assetlinks.json` (and the `www` +host) out of the private `well-known` R2 bucket, for Android App Link +verification (ADFA-5067). + +## Why a Worker and not an Origin Rule + +R2 picks a bucket from the `Host` header, so pointing a path at it with an +Origin Rule needs a host header override plus a DNS record override. Both are +Enterprise-only; the Free plan exposes just the destination-port override. + +A Worker replaces the origin fetch instead of retargeting it. `env.WELL_KNOWN` +is an in-network binding rather than a URL, so no DNS, TLS or `Host` header is +involved and the bucket needs no public hostname at all. **Leave the bucket's +public access disabled** - it is reachable only through this Worker. + +A Redirect Rule to an R2 custom domain is not an alternative: Android's App Link +verifier does not follow redirects. + +## Shape + +- Routes match **exact paths**, not `/.well-known/*`. A wildcard would also + capture `/.well-known/acme-challenge/`, which the origin needs for certificate + renewal. +- A request whose object is missing falls through to the site origin, so the + Worker can never black-hole a path it does not own. +- `Content-Type` is replayed from the object's stored metadata. The uploader + sets `application/json`; re-uploading by hand without `--content-type` yields + `binary/octet-stream` and fails verification. +- No `Cache-Control`. `signing-fingerprint.yml` re-fetches the URL within seconds + of writing it and compares bytes, so a cached copy would fail that check on a + legitimate update. + +## Deploying + +CI only, via `.github/workflows/deploy-well-known-worker.yml` - it runs on any +push touching this directory, and on manual dispatch. + +Prerequisites: + +1. The `well-known` R2 bucket exists in the account. `wrangler deploy` does not + create it. +2. A `CLOUDFLARE_WORKERS_DEPLOY_TOKEN` repository secret with **Account -> Workers Scripts + -> Edit** and **Zone -> Workers Routes -> Edit** on `appdevforall.org`. The + existing `CLOUDFLARE_KEY_ID` / `CLOUDFLARE_SECRET_ACCESS_KEY` pair is an R2 + S3-compatible credential and cannot deploy a Worker. + +## Verifying + +The object is published by the **Print release signing certificate fingerprint** +workflow with `deploy` enabled, which also verifies the served result. By hand: + +```bash +curl -sSI https://www.appdevforall.org/.well-known/assetlinks.json # 200, application/json, no 3xx +adb shell pm verify-app-links --re-verify com.itsaky.androidide +adb shell pm get-app-links com.itsaky.androidide +``` diff --git a/infra/well-known-worker/src/index.js b/infra/well-known-worker/src/index.js new file mode 100644 index 0000000000..110fff4824 --- /dev/null +++ b/infra/well-known-worker/src/index.js @@ -0,0 +1,41 @@ +/** + * Serves the private "well-known" R2 bucket at the request path. + * + * Cloudflare Origin Rules cannot retarget an origin on the Free plan - host + * header, SNI and DNS record overrides are all Enterprise-only - so the bucket + * is reached through an R2 binding instead. env.WELL_KNOWN is an in-network + * handle, not a URL, so the bucket needs no public hostname and its public + * access stays switched off. + */ +export default { + async fetch(request, env) { + // Anything this Worker does not own passes through to the site origin. + if (request.method !== "GET" && request.method !== "HEAD") { + return fetch(request); + } + + // R2 keys carry no leading slash: "/.well-known/assetlinks.json" is stored + // as ".well-known/assetlinks.json". The routes match exact paths, so this + // is the whole path-to-key mapping. + const key = new URL(request.url).pathname.slice(1); + const object = + request.method === "HEAD" + ? await env.WELL_KNOWN.head(key) + : await env.WELL_KNOWN.get(key); + + if (object === null) { + return fetch(request); + } + + const headers = new Headers(); + // Replays the Content-Type recorded at upload time. Digital Asset Links + // requires application/json, which the deploy step sets via --content-type. + object.writeHttpMetadata(headers); + headers.set("etag", object.httpEtag); + + // No Cache-Control on purpose: signing-fingerprint.yml re-fetches this URL + // within seconds of writing the object and compares bytes, so a cached copy + // would fail that check on a legitimate update. + return new Response(request.method === "HEAD" ? null : object.body, { headers }); + }, +}; diff --git a/infra/well-known-worker/wrangler.toml b/infra/well-known-worker/wrangler.toml new file mode 100644 index 0000000000..4a55f0f57c --- /dev/null +++ b/infra/well-known-worker/wrangler.toml @@ -0,0 +1,22 @@ +# Serves /.well-known/assetlinks.json for appdevforall.org out of the private +# "well-known" R2 bucket. Deployed by .github/workflows/deploy-well-known-worker.yml. + +name = "well-known" +main = "src/index.js" +compatibility_date = "2026-08-18" + +# Nothing should reach this Worker except through the routes below; a workers.dev +# URL would expose the bucket on a second, unverified hostname. +workers_dev = false + +# Exact paths, no trailing wildcard. A "/.well-known/*" route would also capture +# /.well-known/acme-challenge/, which the origin needs for certificate renewal. +routes = [ + { pattern = "appdevforall.org/.well-known/assetlinks.json", zone_name = "appdevforall.org" }, + { pattern = "www.appdevforall.org/.well-known/assetlinks.json", zone_name = "appdevforall.org" }, +] + +# binding must match env.WELL_KNOWN in src/index.js. +[[r2_buckets]] +binding = "WELL_KNOWN" +bucket_name = "well-known" From 3adf2d06b918cdb8c60b2522dd57c77382593f75 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 18 Aug 2026 16:35:39 -0700 Subject: [PATCH 3/5] Document the R2 read scope the Worker deploy actually needs Run 32197717567 failed with "Authentication error [code: 10000]" on GET /accounts//r2/buckets/well-known: wrangler resolves the bucket named in the r2_buckets binding before finishing the deploy, so the token needs Workers R2 Storage -> Read on top of Workers Scripts and Workers Routes. Records the API call and the exact error so the next person does not have to rediscover it from a failed run. ADFA-5067 --- .github/workflows/deploy-well-known-worker.yml | 8 ++++++-- infra/well-known-worker/README.md | 13 +++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-well-known-worker.yml b/.github/workflows/deploy-well-known-worker.yml index e9dadbf763..d5a5fce8ae 100644 --- a/.github/workflows/deploy-well-known-worker.yml +++ b/.github/workflows/deploy-well-known-worker.yml @@ -11,8 +11,12 @@ name: Deploy well-known Worker # Host header. The Worker uses an R2 binding instead, so the bucket stays private. # # Requires a CLOUDFLARE_WORKERS_DEPLOY_TOKEN secret with: -# Account -> Workers Scripts -> Edit (upload the script, bind the bucket) -# Zone -> Workers Routes -> Edit (attach the routes on appdevforall.org) +# Account -> Workers Scripts -> Edit (upload the script) +# Account -> Workers R2 Storage -> Read (see below) +# Zone -> Workers Routes -> Edit (attach the routes on appdevforall.org) +# The R2 read scope is not optional: wrangler resolves the bucket named in the +# r2_buckets binding via GET /accounts//r2/buckets/well-known and fails the +# deploy with "Authentication error [code: 10000]" without it. # The existing CLOUDFLARE_KEY_ID / CLOUDFLARE_SECRET_ACCESS_KEY pair is an R2 # S3-compatible credential and cannot deploy a Worker. diff --git a/infra/well-known-worker/README.md b/infra/well-known-worker/README.md index fff90d5e3b..e9798e3105 100644 --- a/infra/well-known-worker/README.md +++ b/infra/well-known-worker/README.md @@ -41,10 +41,15 @@ Prerequisites: 1. The `well-known` R2 bucket exists in the account. `wrangler deploy` does not create it. -2. A `CLOUDFLARE_WORKERS_DEPLOY_TOKEN` repository secret with **Account -> Workers Scripts - -> Edit** and **Zone -> Workers Routes -> Edit** on `appdevforall.org`. The - existing `CLOUDFLARE_KEY_ID` / `CLOUDFLARE_SECRET_ACCESS_KEY` pair is an R2 - S3-compatible credential and cannot deploy a Worker. +2. A `CLOUDFLARE_WORKERS_DEPLOY_TOKEN` repository secret, non-expiring, with: + - **Account -> Workers Scripts -> Edit** + - **Account -> Workers R2 Storage -> Read** - wrangler resolves the bucket + named in the binding via `GET /accounts//r2/buckets/well-known`, and + fails with `Authentication error [code: 10000]` without it + - **Zone -> Workers Routes -> Edit** on `appdevforall.org` + + The existing `CLOUDFLARE_KEY_ID` / `CLOUDFLARE_SECRET_ACCESS_KEY` pair is an + R2 S3-compatible credential and cannot deploy a Worker. ## Verifying From 5adf9724a3deed2ec7cdc588f41aa7c61d2a8221 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 18 Aug 2026 16:42:34 -0700 Subject: [PATCH 4/5] TEMP: probe Cloudflare token scopes in the Worker deploy The deploy fails on GET /accounts//r2/buckets/well-known even with Workers R2 Storage Read on the token. Probe /user/tokens/verify plus the bucket list and bucket detail endpoints to find which grant is missing, and to confirm the stored secret is the token we think it is. To be reverted. ADFA-5067 --- .../workflows/deploy-well-known-worker.yml | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/deploy-well-known-worker.yml b/.github/workflows/deploy-well-known-worker.yml index d5a5fce8ae..818a4a7d0d 100644 --- a/.github/workflows/deploy-well-known-worker.yml +++ b/.github/workflows/deploy-well-known-worker.yml @@ -64,6 +64,33 @@ jobs: fi done + # TEMPORARY (ADFA-5067): wrangler fails the deploy on + # GET /accounts//r2/buckets/well-known with "Authentication error + # [code: 10000]" despite the token carrying Workers R2 Storage Read. + # Probe the endpoints directly to see which grant is actually missing. + # Remove once the required scope is known. Token values are masked by + # Actions and never appear in these response bodies. + - name: TEMP diagnose token scopes + env: + TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + ACCOUNT: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -uo pipefail + + probe() { + printf '\n--- %s\n' "$1" + curl -sS -o /tmp/body -w 'HTTP %{http_code}\n' \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + "https://api.cloudflare.com/client/v4$2" + jq -c '{success, errors, id: (.result.id // empty), n: (.result | if type=="array" then length else empty end)}' \ + < /tmp/body 2>/dev/null || cat /tmp/body + } + + probe "token identity + status" "/user/tokens/verify" + probe "list buckets (needs R2 read)" "/accounts/$ACCOUNT/r2/buckets" + probe "bucket detail (what wrangler calls)" "/accounts/$ACCOUNT/r2/buckets/well-known" + - name: Deploy with Wrangler uses: cloudflare/wrangler-action@v4 with: From 05c847a7714fd8b38aee3742c11dde696f7d18d2 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 18 Aug 2026 16:44:41 -0700 Subject: [PATCH 5/5] Revert the token-scope probe; note the scope propagation delay The probe answered the question: Workers R2 Storage Read is both required and sufficient, and the stored secret was always the right token. The two failures came from re-running about a minute after the scope was added, before it had taken effect. Reverts the TEMP diagnostic step and records the delay next to the scope list so the next person does not read the stale error as a wrong scope. ADFA-5067 --- .../workflows/deploy-well-known-worker.yml | 31 ++----------------- infra/well-known-worker/README.md | 4 ++- 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/.github/workflows/deploy-well-known-worker.yml b/.github/workflows/deploy-well-known-worker.yml index 818a4a7d0d..bad3fa6809 100644 --- a/.github/workflows/deploy-well-known-worker.yml +++ b/.github/workflows/deploy-well-known-worker.yml @@ -16,7 +16,9 @@ name: Deploy well-known Worker # Zone -> Workers Routes -> Edit (attach the routes on appdevforall.org) # The R2 read scope is not optional: wrangler resolves the bucket named in the # r2_buckets binding via GET /accounts//r2/buckets/well-known and fails the -# deploy with "Authentication error [code: 10000]" without it. +# deploy with "Authentication error [code: 10000]" without it. Note that a scope +# added to an existing token takes a few minutes to take effect - that same error +# persists across an immediate re-run, so wait before concluding the scope is wrong. # The existing CLOUDFLARE_KEY_ID / CLOUDFLARE_SECRET_ACCESS_KEY pair is an R2 # S3-compatible credential and cannot deploy a Worker. @@ -64,33 +66,6 @@ jobs: fi done - # TEMPORARY (ADFA-5067): wrangler fails the deploy on - # GET /accounts//r2/buckets/well-known with "Authentication error - # [code: 10000]" despite the token carrying Workers R2 Storage Read. - # Probe the endpoints directly to see which grant is actually missing. - # Remove once the required scope is known. Token values are masked by - # Actions and never appear in these response bodies. - - name: TEMP diagnose token scopes - env: - TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} - ACCOUNT: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - run: | - set -uo pipefail - - probe() { - printf '\n--- %s\n' "$1" - curl -sS -o /tmp/body -w 'HTTP %{http_code}\n' \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - "https://api.cloudflare.com/client/v4$2" - jq -c '{success, errors, id: (.result.id // empty), n: (.result | if type=="array" then length else empty end)}' \ - < /tmp/body 2>/dev/null || cat /tmp/body - } - - probe "token identity + status" "/user/tokens/verify" - probe "list buckets (needs R2 read)" "/accounts/$ACCOUNT/r2/buckets" - probe "bucket detail (what wrangler calls)" "/accounts/$ACCOUNT/r2/buckets/well-known" - - name: Deploy with Wrangler uses: cloudflare/wrangler-action@v4 with: diff --git a/infra/well-known-worker/README.md b/infra/well-known-worker/README.md index e9798e3105..ac5a01d6bf 100644 --- a/infra/well-known-worker/README.md +++ b/infra/well-known-worker/README.md @@ -45,7 +45,9 @@ Prerequisites: - **Account -> Workers Scripts -> Edit** - **Account -> Workers R2 Storage -> Read** - wrangler resolves the bucket named in the binding via `GET /accounts//r2/buckets/well-known`, and - fails with `Authentication error [code: 10000]` without it + fails with `Authentication error [code: 10000]` without it. A scope added + to an existing token takes a few minutes to take effect, and the same + error persists until it does - wait before re-running - **Zone -> Workers Routes -> Edit** on `appdevforall.org` The existing `CLOUDFLARE_KEY_ID` / `CLOUDFLARE_SECRET_ACCESS_KEY` pair is an