Linto
DEVELOPERS

Lintoの技術解説

このページでは、Lintoで使っている開発言語・技術スタック・システム構成と、特に力を入れて設計した エンドツーエンド暗号化まわりのセキュリティ設計を、他の開発者の方の参考になるよう詳しく解説します。 個人開発アプリの実装例として、設計判断の理由や踏んだ制約も含めて公開できる範囲で記載しています。

開発言語・技術スタック

フロントエンド(Android版・Web版)は単一の React Native / Expo コードベースで、プラットフォーム差分は .web.ts / .web.tsx というファイル拡張子の慣習(React Nativeの プラットフォーム拡張)だけで吸収しています。バックエンドは自前サーバーを持たず、Firebase(BaaS)と GCPのサーバーレス基盤のみで構成しています。

領域採用技術
言語TypeScript ~6.0
UIフレームワークReact 19.2 / React Native 0.86 / react-native-web ~0.21
アプリ基盤Expo SDK 57(Managed Workflow)
ルーティングexpo-router ~57(ファイルベースルーティング、Typed Routes有効)
アニメーションreact-native-reanimated 4.5 / react-native-worklets 0.10
状態管理Redux等は未使用。useSyncExternalStoreベースの自前Store(後述)
最適化React Compiler(experiments.reactCompiler)。手動useMemo/useCallbackは書かない方針
暗号化(クロスプラットフォーム)@noble/ciphers・@noble/curves・@noble/hashes(pure TypeScript実装)
暗号化(ネイティブ高速化)react-native-quick-crypto(Nitro Modules経由のネイティブOpenSSL、Android/iOSのみ)
ローカル永続化@react-native-async-storage/async-storage
認証・データベースFirebase Authentication(Googleサインイン)/ Cloud Firestore(firebase JS SDK ^12)
サーバーレス関数Cloud Functions for Firebase(2nd gen, Node.js 22)
バッチ処理基盤GCP Cloud Run Jobs + Cloud Scheduler(独立したnpmプロジェクト、Node.js)
生成AIVertex AI Gemini API(@google/genai)
ホスティングFirebase Hosting(マルチサイト構成、GitHub Actionsで自動デプロイ)
CIGitHub Actions(Web版・ドキュメントサイトそれぞれ専用ワークフロー)
テストJest ~29 / jest-expo / @testing-library/react-native
LintESLint 9 / eslint-config-expo

システム構成

クライアント(Android版・Web版)はFirebase Authentication・Cloud Firestoreと直接通信します。 サーバーサイドのロジックは、AIタグ提案(対話的・低レイテンシが必要)はCloud Functions、 AIリンクの週次収集(バッチ・長時間実行)はCloud Run Jobsと、用途に応じて実行基盤を分けています。 どちらもGeminiの呼び出しにはVertex AIを使い、Firestoreアクセスを含めて静的なAPIキー・秘密鍵を一切持たず、 実行環境にアタッチしたサービスアカウントのApplication Default Credentials(ADC)だけで認証します。

Androidアプリ React Native / Expo Web版(PWA) react-native-web クライアント内の処理例 180日未更新リンクを 自動で倉庫へ(archived) 検索・タグ絞り込み・並び替え →通常どおりFirestoreへ保存 Cloud Firestore link/task/tag はE2E暗号化 AI系データは平文(後述) Firebase Authentication Googleサインイン Cloud Functions (2nd gen) suggestLinkTags 1日5回/ユーザーの上限 Vertex AI Gemini API(ADC認証) Cloud Scheduler 毎週日曜20:00 JST Cloud Run Jobs ai-link-collector 静的な秘密鍵なし(ADC) 外部RSSフィード Google News / Bing News はてなブックマーク Zenn / Qiita E2E暗号化データ / 制御フロー 平文データ(設計上の例外、後述)
クライアント・Firebase・Cloud Functions・Cloud Run Jobsの関係。破線はAI関連の平文データフロー。

ポイントは「同じFirestoreプロジェクトの中に、暗号化必須のデータ(link/task/tag)と、 サーバー側処理のためにあえて平文のままにしているデータ(AIキーワード・AIリンク・AI性格設定・ 週次メッセージ)が併存している」ことです。この区分と、それぞれの保護方法は セキュリティ設計で詳しく説明します。

なお、AIタグ提案(suggestLinkTags)はAndroid版・Web版のどちらからも呼び出せるため、 図では両方のクライアントからCloud Functionsへ矢印を伸ばしています。一方、「180日以上更新していない リンクを自動で倉庫へ移動する」機能はGemini等を使わない純粋なクライアント内のルールベース処理 (src/domain/stale-links.ts)で、外部と通信するわけではありません。図の右上の 破線囲みのように、通常のリンク保存(archived: trueへの更新)と同じ経路で E2E暗号化されたままFirestoreへ書き込まれるだけなので、独立した箱にはせず「クライアント内の処理例」 として注記しています。

技術的に工夫している点

1. プラットフォーム差分を「暗号の中身」まで隠すクロスプラットフォーム設計

Android/iOS版はネイティブOpenSSL実装(react-native-quick-crypto、Nitro Modules経由)、Web版は ブラウザ標準のWebCrypto(crypto.subtle)と、鍵導出の実装がプラットフォームごとに まったく異なります。これをderive-from-passphrase.ts / derive-from-passphrase.web.ts という同一シグネチャの関数として切り出し、呼び出し側(暗号化・復号のロジック)はプラットフォームを 一切意識しません。当初pure-JS実装(@noble/hashesのPBKDF2)だけで統一しようとしましたが、 実機(Pixel 10 Pro XL)で反復回数60万回のPBKDF2に1〜2分かかることが判明し(HermesエンジンにJITが無いため)、 ネイティブ実装への切り替えが必須になりました。結果的に数十ミリ秒まで短縮しています。

2. サーバーが「読めない」設計のE2E暗号化

リンク・タスク・タグの本文はすべてクライアント側でAES-256-GCM暗号化してからFirestoreへ送信します。 復号鍵はユーザーが設定したパスフレーズから導出し、パスフレーズ自体はネットワークに一切送信しません。 Firestoreのデータが仮に漏えいしても、パスフレーズを知らない限り内容を読むことはできません。 詳細はセキュリティ設計を参照してください。

3. ロック解除なしでも共有できる「ブックマークレット即時保存」

Web版はDEK(データ暗号化鍵)をメモリ上にしか保持しないため、タブを閉じるたびにロックされます。 一方でブックマークレットからの共有はロック解除前でも発生しうるため、通常の暗号化フローとは別に、 公開鍵暗号(X25519 + HKDF + AES-256-GCM)だけでURLを安全に一時保存できるpendingLinks という仕組みを別途用意しています。秘密鍵を必要とせず公開鍵だけで暗号化できるため、ロック中でも データを失わずに受け取れます。詳細はセキュリティ設計内の解説を参照してください。

4. Firestoreルールの「OR結合」を逆手に取らせない設計

Firestoreセキュリティルールは複数のmatchブロックがOR結合されるため、 「ユーザー自身の書き込みを許可する緩いワイルドカードルール」と「サーバーのみ書き込み可能な 厳格なルール」が両方存在すると、後者をusers/{uid}/配下に置いた場合に前者の緩いルールが 先に一致してしまい、改造クライアントから直接書き込めてしまう抜け道になります。AIタグ提案の レート制限カウンタ(aiTagUsage / aiTagLimits)はこの理由から意図的に ルート直下の別コレクションに配置し、allow write: if falseで固めています。 詳しくは後述します。

5. 静的シークレットを一切持たないサーバーレス基盤

Cloud Functions・Cloud Run Jobsのどちらも、FirestoreへのアクセスとGemini(Vertex AI)の呼び出しを すべて実行環境のサービスアカウントによるApplication Default Credentials(ADC)で行い、 .envやSecret Managerに保存する静的なAPIキー・秘密鍵ファイルを持ちません。 旧構成(Cloudflare Workers版)ではFirebaseサービスアカウントの秘密鍵JSONと静的なGemini APIキーの 2つを管理する必要がありましたが、GCPへ移行したことでこれらの静的シークレットそのものを 削除できました。

セキュリティ設計(詳細)

ここでは、他の開発者の方の参考になるよう、暗号化・鍵管理・アクセス制御の設計をできるだけ具体的に 説明します。まず前提として、このアプリの脅威モデルを明確にしておきます。

脅威モデル
守る対象: Firestoreのデータそのものが漏えいした場合でも、リンク・タスク・タグの本文 (URL・タイトル・説明・チェックリスト・タグ名)をパスフレーズなしに読めないようにすること。
守らない対象(既知のトレードオフ): 作成日時・更新日時・完了フラグ等のメタデータは 暗号化していません。また、AIキーワード・AI収集リンク・AI性格設定・週次メッセージは Gemini APIにサーバー側から渡す都合上、意図的に平文で保存しています (個人を強く特定する機密情報ではないキーワード・記事タイトル・要約に限定)。

鍵導出: PBKDF2-HMAC-SHA256、60万回反復

ユーザーが設定するパスフレーズから、PBKDF2-HMAC-SHA256(RFC 8018)で 鍵暗号化鍵(KEK)を導出します。反復回数は600,000回とし、これは OWASPのPassword Storage Cheat Sheetが2023年に示したPBKDF2-HMAC-SHA256の 推奨値に合わせています。ソルトはユーザーごとにランダム生成し、反復回数とともに users/{uid}/cipherMaterial/mainに保存します(パスフレーズ自体はどこにも保存・送信しません)。

DEKのラップ/アンラップとキャッシュ方式

実際にフィールドを暗号化するのはKEKではなく、ランダム生成したデータ暗号化鍵(DEK)です。 DEKはKEKでラップ(AES-GCMで暗号化)した状態でFirestoreに保存し、平文のDEK自体は サーバー側に一度も送信しません。プラットフォームごとのDEKキャッシュ方針は次の通りです。

プラットフォームロック解除後のDEK保持先再入力が必要になるタイミング
Android / iOSexpo-secure-store(Keychain / EncryptedSharedPreferences)基本的に再起動後も保持される
Webメモリ上のみ(永続化しない、意図的な設計)タブを閉じる・再読み込みするたび

Web版でメモリ保持のみにしているのは、ブラウザにはOSレベルのSecure Enclave/Keystoreに相当する 安全な永続領域が無く、localStorage等に置くとXSS等で読み取られるリスクが上がるためです。 利便性より安全性を優先したトレードオフとして、意図的にこの設計にしています。

暗号化フィールド一覧

エンティティ暗号化する項目平文のまま保存する項目
Linkurl, title, description, tags(JSON配列)archived, createdAt, updatedAt
Tasktitle, description, checklist(JSON), tags(JSON)id, 日時関連, allDay, manualProgress, completed, calendarReminders, linkedLinkUrls, createdAt, updatedAt
登録済みタグnamecreatedAt, updatedAt
AIキーワード(対象外)keyword, createdAt
AIリンク(対象外)url, title, summary, whyRead, keyword, publishedAt, createdAt
AI性格設定 / 週次メッセージ(対象外)persona, message, updatedAt/createdAt
pendingLinksurl, title(X25519公開鍵暗号方式、後述)ephemeralPublicKey, createdAt

メタデータ(日時・完了フラグ等)を平文にしているのは、並び替え・絞り込みをFirestoreクエリ側で 行うためです。これらの暗号化にはあまり機密性が無く、暗号化するとクエリが組めなくなる デメリットの方が大きいと判断しました。

メールアドレス等のアカウント情報の保存・管理方針

上の表の対象はいずれもLinto独自のFirestoreドキュメント(users/{uid}/...配下)です。 一方、Googleサインイン時に取得するメールアドレス・表示名はFirestoreドキュメントとしては 一切保存していません。これらはFirebase Authenticationが管理するユーザーレコードそのもので、 クライアントはfirebase/authのSDKが返すUserオブジェクト(サインイン中のみ メモリ上に保持)から都度読み取り、設定画面での表示にのみ使っています (src/data/auth/default-auth-repository.tsのtoAuthUser、 src/domain/auth-user.ts)。アプリ側のコードがメールアドレスをFirestoreの どこかのフィールドへ書き込む処理は存在しません。

Firebase Authenticationのユーザーレコード自体(メールアドレスを含む)は、GoogleのFirebase基盤側で 保存時暗号化を含む標準的なセキュリティ管理下に置かれます。ただし、これはGoogleが提供する認証基盤の 固定スキーマであり、パスフレーズ由来のDEKでフィールド単位に暗号化するLinto独自のE2E暗号化の対象には 含められません(サインイン・アカウント復旧といった認証機能自体がGoogle側でメールアドレスを 照合できる必要があるため、アプリ側で追加暗号化することもできません)。この点はプライバシーポリシーの 「1-1. アカウント情報」にも明記しています。

メールアドレスの二次利用・複製は行わない方針を徹底しています。具体的には、

ブックマークレットや共有シート経由の「クイックキャプチャ」は、Web版がロック解除されていなくても URLを失わずに受け取れる必要があります。そこで通常のDEKベースの暗号化とは別に、公開鍵暗号(ECIES類似の ハイブリッド方式)を使っています。

① キャプチャ(ロック中でも可) ブックマークレット URL・タイトルを取得 一時鍵ペア生成 → ECDH HKDF-SHA256 → AES-256-GCM ユーザーの公開鍵のみ使用 pendingLinks(Firestore) 暗号文 + ephemeralPublicKey 秘密鍵を知らないと読めない ② 受信(ロック解除後) 自分の秘密鍵をDEKでアンラップ ECDH → HKDF-SHA256 → 復号 通常のLinkとして再暗号化 保存後、pendingLinksから削除 (useDrainPendingLinks)
秘密鍵はDEKロック解除後にしか使えないため、キャプチャ側(左)は公開鍵だけで安全に暗号化できる。

Firestoreルールの落とし穴: ワイルドカードとOR結合

Firestoreセキュリティルールでは、複数のmatchブロックの条件はORで 評価されます。Lintoのメインルールはユーザー自身の全サブコレクションを許可する広いワイルドカードです。

match /users/{userId}/{document=**} {
  allow read, write: if request.auth != null && request.auth.uid == userId;
}

もしAIタグ提案のレート制限カウンタをusers/{uid}/aiTagUsage/...のように このワイルドカードの配下に置いてしまうと、より厳格なルールを別途書いても、 上記の緩いルールが先に一致するため後から書いた厳格なルールでは上書きできません (AND結合ではなくOR結合であるため)。改造したクライアントから直接カウンタを書き換えれば レート制限をすり抜けられてしまいます。そのため、レート制限カウンタは意図的にルート直下の 別コレクションに切り出し、クライアントからの書き込みを完全に禁止しています。

// ルート直下: ワイルドカードの対象外になる
match /aiTagUsage/{docId} {
  allow read, write: if false; // Cloud Functionsの Admin SDK はルールを経由しないため書き込み可能
}
match /aiTagLimits/{uid} {
  allow read: if request.auth != null && request.auth.uid == uid;
  allow write: if false;
}

レート制限自体は、Cloud Functions側でFirestoreのトランザクションを使って 「本日の呼び出し回数が上限未満なら原子的に+1してtrueを返す」という形で実装し(1ユーザー1日5回)、 読み取りと書き込みの間に競合が起きないようにしています。

Gemini API・各サーバーへのアクセス制御と最小権限設計

Firestore以外のバックエンド(Cloud Functions・Cloud Run Jobs・Vertex AI Gemini API)についても、 「誰が」「どの権限で」呼び出せるかを整理しておきます。実装済みの部分と、まだ対応できていない 部分(今後の課題)を区別して、正直に記載します。

コンポーネント呼び出し元を制限する仕組み実行時の権限(IAM)状態
Cloud Firestore セキュリティルールでrequest.auth.uid一致のみ許可 - 実装済み(前述)
suggestLinkTags
(Cloud Functions 2nd gen)
Firebase AuthのIDトークンを関数コード側で検証(request.auth) 専用サービスアカウント(linto2-functions@linto2.iam.gserviceaccount.com)にroles/aiplatform.user・roles/datastore.userのみ付与 実装済み(最小権限)
Vertex AI Gemini API(Cloud Functions経由) クライアントから直接は呼べず、上記Cloud Functionsのサービスアカウント経由のみ 同上 実装済み
ai-link-collector
(Cloud Run Jobs)
Cloud Schedulerのみが対象Jobへのrun.invoker権限を持つ(外部から直接execute不可) 専用サービスアカウント(ai-link-collector@linto2.iam.gserviceaccount.com)にroles/datastore.user・roles/aiplatform.userのみ付与 実装済み(最小権限)
Firebase Authサインイン自体 GoogleのOAuthクライアント登録(Android: パッケージ名+署名証明書のSHA-1、Web: 承認済みJavaScriptオリジン) - 実装済み

ネットワーク層とアプリ層のアクセス制御を分けて考える必要がある点が、Cloud Functions (2nd gen)特有の注意点です。suggestLinkTagsはFirebaseのcallable function (onCall)として実装していますが、実体はCloud Run上で動作しており、Cloud Run自体の IAM(呼び出し権限)はallUsersに開放されています。一見「誰でも呼べる」ように見えますが、 これはFirebase callable functionの標準的な構成で、実際の認証はネットワーク層ではなく 関数コードの内部で行われます。クライアントSDK(getFunctions / httpsCallable)がリクエストにFirebase AuthのIDトークンを自動で添付し、 functions/src/index.ts側でrequest.authの有無を確認、未認証なら 即座にunauthenticatedエラーを返して拒否します。つまり「サインイン済みFirebaseユーザーか」 はコードレベルで厳格にチェックしていますが、「Linto公式アプリからの呼び出しか」までは 現状チェックしていません。

サービスアカウントの権限設計は、ai-link-collector(Cloud Run Jobs)・ functions/(Cloud Functions 2nd gen/1st gen)とも専用のサービスアカウントを 新規作成し、Firestore・Vertex AIに必要な最小限のロール(roles/aiplatform.user・ roles/datastore.user)のみを付与する方針で統一しています(2026-09-26対応。 それ以前はfunctions/側がデプロイ時に指定を省略していたため、他のCloud Run/ Compute系サービスとも共有されうるCompute Engineの既定サービスアカウントで実行されていました)。

まだ対応できていない項目(今後の課題として公開)

これらはいずれも「多層防御の追加レイヤー」であり、現時点でも Firestoreセキュリティルール(uidベースのアクセス制御)・E2E暗号化(サーバーが本文を読めない)・ Cloud FunctionsのFirebase Auth検証・各サービスアカウントの権限スコープという複数の防御は 機能しています。ただし、特定のクライアントアプリ(改ざんされていない公式ビルド)からの 呼び出しであることまでは、App Check導入前の現状では技術的に保証できていません。

Google Play Store初期公開に向けた費用対効果評価

上記項目について、「利便性への影響」「対策効果」「実装コスト」「品質リスク」の4軸で評価し、 Google Play Storeでの初期公開時点で対応すべきかを判断しました。

対策利便性への影響対策効果実装コスト品質リスク初期公開時点での要否
Firebase App Check
(Play Integrity)
中〜高 中 高 中〜高 見送り推奨
Firebase APIキー制限
(アプリ制限・API制限)
ほぼ無し 中 低 低 対応中
Cloud Functions専用
サービスアカウント分離
無し 低〜中 低 低 対応済み(2026-09-26)

App Checkを初期公開では見送る理由:

APIキー制限はコード変更・再ビルドが不要でGoogle Cloud Console上の設定のみ のため、費用対効果が良く初期公開前後での対応を進めています(AndroidのSHA-1登録では、 デバッグ鍵・ローカルリリース鍵に加えてPlay App Signingの署名鍵SHA-1の登録漏れに注意が必要です)。 専用サービスアカウント分離はai-link-collectorと同じパターンで 対応済みです(suggestLinkTags・provisionAiTagLimits・ removeAiTagLimitsの3関数をlinto2-functions@linto2.iam.gserviceaccount.comで 実行するようコード側を変更し、サービスアカウントの作成・権限付与・再デプロイの手順を OPERATIONS.mdに整備しました)。

ログに平文を残さない設計

復号済みの本文(タイトル・説明・URL等)はログに一切出力しません。エラーログ共通窓口 (logError/logWarn)はローカルのconsole出力に加え、保守運用の 問い合わせ対応のためにuid付きでFirestoreのerrorLogsコレクションへも 記録しますが、書き込み可能なフィールドをhasOnly([...])で厳格に許可リスト化し、 context/message文字列にサイズ上限を設け、クライアントからの 読み取り・更新・削除は一切許可していません(createのみ、確認は運用手順書に基づき コンソール側で行う)。クライアントIPは自己申告させず(詐称可能なため)、必要な場合はGoogle Cloud Audit LogsのData Accessログ(protoPayload.requestMetadata.callerIp)を利用する方針です。

データ保管・バックアップ・リストア設計と障害対応

ここまでは「漏えい・不正アクセス」への対策(機密性)が中心でしたが、ここでは 「誤削除・障害・災害によるデータ損失」への対策(可用性・完全性)を扱います。 保管設計、バックアップ・リストア設計、そして障害発生時に何をどこで確認するかという ログ運用について、実装済みの部分と、手順は整備済みだが実際にはまだ有効化していない部分を 区別して記載します。

保管設計: データはどこに、どう冗長化されているか

正のデータストアはCloud Firestore(Google管理)です。Firestore Native modeは、 データベース作成時に選んだロケーション(リージョン型/マルチリージョン型)に応じて、 Google側のインフラが自動的に複数ゾーン(リージョン型の場合)または複数リージョン (マルチリージョン型の場合)へ同期レプリケーションします。これは開発者側で追加の構成をせずとも Firestore自体が備える冗長性で、単一データセンター障害でデータが失われることはまずありません。

クライアント側(AsyncStorage)にも復号済みのローカルコピーが存在しますが、これはオフライン 閲覧・表示用のキャッシュであり、永続的なバックアップとしては扱っていません。端末の紛失や アプリの再インストールで失われる前提のデータです。

想定する損失シナリオと現状の対応

シナリオ現状の対応
誤ってリンク・タスクを削除/上書き編集してしまった アプリ内にUndo機能は無し。後述のPITR・スケジュールバックアップが有効化されていれば 復旧可能だが、現時点では未有効化(下記参照)。
運用ミス・スクリプトの不具合等でFirestoreのデータが意図せず失われた 同上。PITR・スケジュールバックアップの運用手順は整備済みだが未有効化。
端末を紛失・機種変更した Googleサインイン+クラウド同期により別端末で復元可能(パスフレーズが分かる場合)。 未サインイン時にローカルのみへ保存したデータは同期対象外のため端末とともに失われる。
暗号化パスフレーズを忘れた データ自体はFirestore上に残っていても復号不能。サーバー側に復旧経路を持たない E2E暗号化の設計上、意図した挙動(詳細は後述「E2E暗号化とバックアップの限界」)。
手元に明示的なバックアップファイルを残しておきたい 設定画面のインポート・エクスポート機能で手動バックアップ・復元が可能(実装済み、詳細後述)。

サーバー側バックアップ・リストア設計(PITR・スケジュールバックアップ)

Firestoreには2種類のバックアップ機構があり、運用手順としてOPERATIONS.mdに 整備済みです。2026年9月26日時点では手順のみ用意されており、実際のgcloud/Google Cloud Console側での有効化はまだ行っていません(下記の「初期公開に向けた評価」で 有効化を推奨しています)。

Firestoreのバックアップは既存データベースへの直接上書き復元ができないため、 「新しいデータベースとして復元してから、内容を確認し本番へ反映する」という2段階の設計になります。

①障害・誤削除 発生 ②新規DBへ復元 restored-YYYYMMDD (PITR or 日次バックアップから) ③内容確認 Firebase Console等 ④本番へ反映 全体切替 or 特定 コレクションのみexport/import ⑤復元用DBを 削除 特定ユーザー1人分だけの復旧も可能: PITR経由でexport → 一時DBへimport → 対象uidのサブツリーのみAdmin SDKで抽出、という個別手順も用意しています。
Firestoreは既存データベースへの直接上書き復元ができないため、新規データベースへ復元→確認→反映という2段階の手順になる。

クライアント側バックアップ(インポート・エクスポート機能、実装済み)

設定画面から、保存済みリンクをJSONファイルとして手動でエクスポート・インポートできます (IMPORT_EXPORT.md参照)。ローカルの復号済みリポジトリ全件を対象に、Androidは Storage Access Framework、Web版はFile System Access API(非対応ブラウザは通常のダウンロードに フォールバック)でファイルを保存します。インポートはURL一致で重複を除外する「追記」方式で、 このアプリのJSON形式に加えてChromeのブックマークHTML形式も受け付けます。

重要な注意点: エクスポートされるJSONファイルはローカルの復号済みデータから 直接出力するため、ファイル自体は暗号化されていない平文です。E2E暗号化の セキュリティ境界の外に出るデータのため、保存先(クラウドストレージ等に置く場合は特に)は 利用者自身の管理にご注意いただく設計になっています。

E2E暗号化とバックアップ・リストアの根本的な限界

PITR・スケジュールバックアップ・インポート/エクスポートはいずれも「暗号化されたままのデータ、 または復号済みのローカルデータ」を保護する仕組みであり、暗号化パスフレーズの紛失による データ喪失そのものは防げません。これはサーバー側に復旧経路(パスフレーズのリセット手段や バックドア)を一切持たないゼロ知識設計の帰結であり、意図した仕様です。バックアップ設計を どれだけ強化しても、この一点だけは技術的にトレードオフの外に出せない前提として明記しておきます。

障害発生時のログ運用(形式・保管場所・解析手順)

何を記録し何を絶対に記録しないかという方針は前述の 「ログに平文を残さない設計」の通りです。ここでは、実際に障害・不具合が 起きたときに、どの形式のログが・どこに・どれくらいの期間保管され、解析時にどこを見ればよいかを 整理します。

ログの種類形式・保管場所解析時に見る場所
クライアント(アプリ本体)のエラー・警告 Firestoreルート直下errorLogsコレクション。1件1ドキュメントで { uid, context, message, level: 'error'|'warn', platform: 'android'|'ios'|'web', createdAt }という固定フィールドのみ(hasOnlyで強制)。クライアントからの 読み取りは不可(createのみ許可)。自動削除の仕組みは無く、アカウント削除後も 意図的に保持される(調査可能性を優先した設計。個人情報保護の観点での定期棚卸しは今後の課題)。 Firebase Console → Firestore Database → errorLogs → uidフィールドで対象ユーザーに絞り込み。contextで発生箇所、 messageでエラー内容、level/platformで種別・端末を確認 (OPERATIONS.md5-1節)。
suggestLinkTags(Cloud Functions/Cloud Run) 標準出力・例外が自動的にCloud Loggingへ構造化ログとして収集される(severityが ERRORのエントリにスタックトレースを含む)。保持期間はCloud Loggingの既定 (変更していなければ30日)。 Google Cloud Console → Logging → ログ エクスプローラ。または gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="suggestlinktags"' --project=linto2 --freshness=1dで HTTPステータス・レイテンシ・エラー詳細を確認(OPERATIONS.md6-2節)。
ai-link-collector(Cloud Run Jobs、週次バッチ) 標準出力が自動的にCloud Loggingへ収集される。キーワード文字列・収集記事の内容は 一切出力しない(意図的な設計)。 ログ エクスプローラでresource.type="cloud_run_job" resource.labels.job_name="ai-link-collector" textPayload:"uid=<対象uid>"を クエリし、実行日時(毎週日曜20:00 JST)で絞り込む(OPERATIONS.md5-2節)。
接続元IPアドレス(不正アクセス調査) クライアントからは送信・記録していない(自己申告IPは信頼できないため)。Google Cloud Audit LogsのData Accessログを使うが既定では無効(ログ量・コストの都合)。 調査が必要になった時点でCloud Console →「IAMと管理」→「監査ログ」から該当APIの 「データ読み取り」「データ書き込み」を一時的に有効化し、 protoPayload.requestMetadata.callerIpを確認後、OFFに戻す運用 (OPERATIONS.md5-3節)。

現状の既知の限界として、(1) 未サインイン状態で発生したエラーはFirestoreへ書き込めないため ローカルconsole出力のみに残る(サインイン前の不具合はリモートから追えない)、(2) Cloud Monitoringによる自動アラート・ page 通知は設定しておらず、障害の検知は手動でのログ確認 (週1回の問い合わせフォーム確認等)に依存している、という2点があります。

Google Play Store初期公開に向けた費用対効果評価

ここまでの項目についても、前節と同じ4軸(利便性への影響・対策効果・実装コスト・品質リスク)で 初期公開時点での要否を評価しました。

対策利便性への影響対策効果実装コスト品質リスク初期公開時点での要否
PITR有効化 無し(バックグラウンドで動作) 高(直近7日の誤削除・誤更新から復旧可能) 低(コマンド1つ) 低 推奨
日次スケジュールバックアップ 無し 高(PITRの7日を超えた障害シナリオにも対応) 低(コマンド1つ) 低 推奨
errorLogsの保持期間ポリシー明文化・定期削除 無し 低(個人情報保護の衛生対策、障害対応そのものには寄与しない) 低〜中(削除スクリプトの用意が必要) 低 任意(初期公開後でよい)
Cloud Monitoringでの自動アラート導入 無し 中(検知の即時性が上がるが、現状の利用規模では手動確認でも実務上足りる) 中 低 任意(利用者数が増えてから)

PITR・日次スケジュールバックアップはApp Checkとは対照的に、ユーザー影響ゼロ・低コスト・ 高い効果という組み合わせのため、Firebase APIキー制限と同様に初期公開前後での 有効化を推奨します。特にアプリ内に「アカウントとデータの初期化」という取り消し不能な 自己サービス削除機能が既に存在するため、実装バグや操作ミスが起きた際のセーフティネットとして PITRの価値は高いと判断しています。

他アプリとの差別化ポイント

参考文献・参照URL

実装の前提とした仕様・ドキュメントです。