我們的 Angular 應用程式目前已部署至 Firebase App Hosting,但尚未設定 Firebase API Key、Auth Domain、專案 ID、應用程式 ID、Storage Bucket URL、訊息傳送者 ID (Message sender ID) 以及 reCAPTCHA Enterprise 金鑰。
因為我們尚未在前端撰寫任何 Firebase 邏輯,所以部署能夠順利成功。但只要我們加入第一個 Firebase 呼叫,正式環境建置(Production build)就會立刻中斷。
我們可以在 Firebase 使用者介面 (UI) 中手動將環境變數新增至後端。如果這是一次性的操作,對大多數人來說應該不算太麻煩。
但作為一個經常建立新展示後端的內容創作者,每次都要複製並貼上 7 個獨立的數值既繁瑣又容易出錯。(沒錯,我以前就曾不小心將金鑰貼到錯誤的欄位中)。
我可以將環境變數直接寫入 apphosting.yaml,但 GitHub 會針對金鑰外洩發出警示。處理這些誤報的安全警示是沒有人想要的麻煩事。
我在 Antigravity CLI 中向 Gemini 提出了我的問題,它建議了一個結合 Firebase CLI、apphosting.yaml 以及 Husky 的 pre-push hook 的解決方案。
以下是我在 Antigravity CLI 對話中提供給 grill-with-docs 技能的需求。
apphosting.yaml 中新增環境變數,但不能是純文字。否則 GitHub 會針對密鑰外洩向我發出警示.env 至少包含 Web 應用程式名稱以及 reCAPTCHA Enterprise 金鑰。apphosting.yaml 應該作為單一事實來源(Single source of truth)。不要在 Yaml 檔案和 Node.js 指令碼中重複定義環境變數名稱Gemini 與該技能產生了這份架構決策記錄 (ADR) 並提出了以下解決方案。
package.json 中新增一個執行該指令碼的指令碼main 分支時觸發同步透過 AI,我得以開發出一個具備擴展性、精密且符合企業級標準的解決方案。
在接下來的幾個小節中,我們將展示 apphosting.yaml 以及同步 Secret 的 Node.js 指令碼之關鍵實作。
runConfig:
minInstances: 0
maxInstances: 2 # Prevents Google Cloud from scaling up infinitely during an attack
env:
- variable: APP_FIREBASE_API_KEY
secret: firebase_api_key
- variable: APP_FIREBASE_AUTH_DOMAIN
secret: firebase_auth_domain
- variable: APP_FIREBASE_PROJECT_ID
secret: firebase_project_id
- variable: APP_FIREBASE_STORAGE_BUCKET
secret: firebase_storage_bucket
- variable: APP_FIREBASE_MESSAGING_SENDER_ID
secret: firebase_messaging_sender_id
- variable: APP_FIREBASE_APP_ID
secret: firebase_app_id
- variable: APP_FIREBASE_RECAPTCHA_ENTERPRISE_KEY
secret: firebase_recaptcha_enterprise_key
apphosting 檔案新增了 env 區段,用來維護 Firebase API Key、Auth Domain、專案 ID、應用程式 ID、Storage Bucket、訊息傳送者 ID 以及 reCAPTCHA Enterprise 金鑰。
這些 Secret 並非寫死在程式碼中(Hardcoded),而是透過 Secret 名稱表示,例如 firebase_api_key 和 firebase_project_id。
APP_FIREBASE_WEB_APP_NAME="<Firebase Web App Display Name>"
APP_FIREBASE_RECAPTCHA_ENTERPRISE_KEY="<Recaptcha Enterprise Key>"
APP_FIREBASE_APPCHECK_DEBUG_TOKEN="<App Check debug token>"
.env 檔案包含三個變數,但 App Hosting 並不需要 APP_FIREBASE_APPCHECK_DEBUG_TOKEN。如果不小心部署了 APP_FIREBASE_APPCHECK_DEBUG_TOKEN,將會是嚴重的安全性漏洞。這意味著 App Check 會被繞過,任何請求都能存取 Firebase 資源。
const execAsync = promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const firebaseDir = path.resolve(__dirname, '..');
const projectRootDir = path.resolve(firebaseDir, '..');
const envPath = path.join(firebaseDir, '.env');
const appHostingYamlPath = path.join(projectRootDir, 'apphosting.yaml');
function parseAppHostingSecrets(yamlPath) {
const content = fs.readFileSync(yamlPath, 'utf8');
const secretEntries = [];
const regex = /-\s+variable:\s*([^\s]+)\s+secret:\s*([^\s]+)/g;
let match;
while ((match = regex.exec(content)) !== null) {
secretEntries.push({
variableName: match[1],
secretName: match[2],
});
}
return secretEntries;
}
function resolveSecretMappings(declaredSecrets, sdkConfig) {
return declaredSecrets.map(({ variableName, secretName }) => {
const sdkKey = variableName
.replace(/^APP_FIREBASE_/, '')
.toLowerCase()
.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (sdkConfig && sdkConfig[sdkKey]) {
return { secretName, secretValue: sdkConfig[sdkKey] };
}
const envValue = process.env[variableName];
if (envValue && !envValue.startsWith('<')) {
return { secretName, secretValue: envValue };
}
});
}
async function fetchSdkConfigViaCli(webAppName) {
const { stdout: listStdout } = await execAsync('npx firebase apps:list WEB --json --project default', {
cwd: firebaseDir,
});
const apps = JSON.parse(listStdout)?.result || [];
const targetApp = apps.find((app) => app.displayName === webAppName);
const { stdout: sdkStdout } = await execAsync(`npx firebase apps:sdkconfig WEB ${targetApp.appId} --json --project default`, {
cwd: firebaseDir,
});
return JSON.parse(sdkStdout)?.result?.sdkConfig;
}
function runSpawnCommand(secretName, secretValue, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(
'npx',
['firebase', 'apphosting:secrets:set', secretName, '--data-file', '-', '--force', '--project', 'default'],
{ cwd, stdio: ['pipe', 'inherit', 'inherit'] },
);
child.on('close', () => resolve());
child.stdin.write(secretValue);
child.stdin.end();
});
}
async function syncAllSecrets(mappings, cwd) {
await Promise.all(
mappings.map(({ secretName, secretValue }) => runSpawnCommand(secretName, secretValue, cwd)),
);
const allSecretNames = mappings.map((m) => m.secretName);
const secretList = allSecretNames.join(',');
await execAsync(`npx firebase apphosting:secrets:grantaccess ${secretList} -b ng-test --project default`, {
cwd,
})
}
/* ... load and validate environment variables ... */
const declaredSecrets = parseAppHostingSecrets(appHostingYamlPath);
const sdkConfig = await fetchSdkConfigViaCli(process.env.APP_FIREBASE_WEB_APP_NAME);
const mappings = resolveSecretMappings(declaredSecrets, sdkConfig);
await syncAllSecrets(mappings, firebaseDir);
parseAppHostingSecrets 會剖析 apphosting.yaml 以擷取變數與 Secret 名稱。fetchSdkConfigViaCli 擷取 SDK 設定並將該物件傳遞給 resolveSecretMappings,藉此建立 Secret 名稱與 SDK 設定值之間的對應關係。最後,syncAllSecrets 將 Secret 名稱設定為正確的值,並授予 ng-test 後端存取這些 Secret 的權限。
我們需要一個 pre-push hook 來執行程式碼以同步 Secret。
"scripts": {
"config:secrets": "node firebase/scripts/deploy-apphosting-secrets.mjs",
}
在 package.json 中新增 config:secrets 指令碼以供 pre-push hook 執行
while read -r local_ref local_oid remote_ref remote_oid; do
if [ "$remote_ref" = "refs/heads/main" ] && [ "$local_oid" != "(delete)" ] && [ "$local_oid" != "0000000000000000000000000000000000000000" ]; then
npm run config:secrets
break
fi
done
if 判斷式可確保不會在功能分支(feature branch)上執行該指令碼,以節省時間。在我們將功能分支合併到 main 分支後,推送 main 分支即可同步 Secret,並在線上站台驗證新的變更。
這個解決方案保證了無需手動複製貼上、在 apphosting.yaml 中沒有寫死的數值,並且能使用最新的環境變數進行部署。
推送到功能分支會略過 Secret 同步以節省時間。一旦合併後,推送到 main 分支即會部署您的變更並將最新的 Secret 同步到線上站台。
這種方法徹底消除了複製貼上的繁瑣步驟、避免了在 apphosting.yaml 中寫死 Secret,並確保了乾淨且安全的部署。
今天就到這裡。明天我們將在 Angular 應用程式中撰寫程式碼,以初始化 Firebase App、App Check 並啟用 Remote Config。