شعار أوسكار أوسكار كل المقالات

قواعد Firestore الآمنة عملياً: من الصفر إلى قواعد إنتاج

واجهتُ العطل في أسوأ صورة ممكنة: منتدى يعمل عندي وعند كل من اختبر معي، ثم تصل شكوى بأن مجموعة كاملة من العضوات لا تستطيع نشر موضوع واحد. القراءة تعمل، الدخول يعمل، الملف الشخصي يظهر، وعند الضغط على «نشر» رسالة واحدة باردة من العميل: Missing or insufficient permissions. لا سطر في السجلّات، ولا تفصيل عن أي شرط سقط، ولا فرق بين «حسابك محظور» و«قاعدتك بها خطأ برمجي». استغرق التشخيص يومين، والسبب في النهاية لم يكن صلاحية ناقصة على الإطلاق: كان سطراً في دالة مساعدة يقرأ مفتاحاً غير موجود في رمز الهُويّة، فيُنتج خطأً في تقييم القاعدة — والخطأ في تقييم قاعدة Firestore يعني رفضاً كاملاً لا تحذيراً ولا تجاوزاً للشرط. والمصيبة أن الشريحة المتضرّرة كانت من سجّلن بالجوال لا بالبريد، وأنا أختبر بحساب بريد، فالخطأ لا يظهر عندي أبداً ولو اختبرت مئة مرة. هذا المقال ليس شرحاً نظرياً لصيغة القواعد؛ هو ما تعلّمناه من ملف قواعد إنتاجي حقيقي فيه ثلاثة أدوار ومحتوى عام ومحتوى خاص ومكتبة صور وعدّادات حماية، وما جرّبناه وفشل قبل أن يستقرّ على شكله الحالي.

الخطأ القاتل الأول: قواعد الاختبار التي تُنسى

كل مشروع Firestore يبدأ بخيارين: «وضع الإنتاج» (كل شيء مرفوض) أو «وضع الاختبار» بقاعدة واحدة على شكل allow read, write: if request.time < timestamp.date(...) صالحة ثلاثين يوماً. وبعد الثلاثين يتعطّل التطبيق بالكامل في أسوأ لحظة، فيفتح المطوّر الكونسول مستعجلاً ويحوّل الشرط إلى if true ليعود العمل «مؤقتاً» — وهذا المؤقت هو ما يبقى سنة.

عملياً if true على مجموعة واحدة يعني أن أي شخص يعرف معرّف مشروعك — وهو موجود في كود الواجهة، غير قابل للإخفاء — يستطيع سحب المجموعة كاملة أو محوها بأمر واحد من المتصفّح. لا حاجة لاختراق ولا لثغرة؛ القاعدة نفسها هي التصريح.

لذلك بدأنا الملف بنسخة القواعد الحديثة وأغلقناه بمنع صريح لكل ما لم نصرّح به:

rules_version = '2';
match /{document=**} {
  allow read, write: if false;
}

وهنا نقطة يخطئ فيها كثيرون: هذا البلوك لا «يُلغي» ما سمحتَ به أعلاه؛ صلاحيات Firestore تُجمَع بمنطق «أو» ولا قاعدة تُبطل أخرى. فائدته ضمان أن كل مجموعة تُضاف غداً تبدأ مرفوضة حتى نكتب قاعدتها بوعي: تفشل بصوت عالٍ في التطوير بدل أن تُفتح صامتة في الإنتاج.

دوال مساعدة: القاعدة تُقرأ كجملة عربية

القواعد التي تُكتب شرطاً طويلاً في كل سطر تصبح غير قابلة للمراجعة بعد أسبوعين. ما استقرّ عندنا طبقة دوال صغيرة في أعلى الملف، كل واحدة تجيب سؤالاً واحداً:

function isAuthed() { return request.auth != null; }
function isSelf(userId) { return isAuthed() && request.auth.uid == userId; }
function isAdminOrLeader() { return isAdmin() || isLeader(); }

والدوران isAdmin() وisLeader() يقرآن الدور من مستند الملف الشخصي بعد التأكّد من وجوده:

exists(/databases/$(database)/documents/profiles/$(request.auth.uid))
&& get(/databases/$(database)/documents/profiles/$(request.auth.uid)).data.role == 'leader'

الفائدة ليست جمالية فقط. صار سطر الصلاحية في بيانات المستخدمات يُقرأ كجملة مفهومة: allow read: if isSelf(userId) || isAdminOrLeader();. وحين أضفنا دور «المشرفة» لاحقاً، عدّلنا دالة واحدة لا أربعين سطراً موزّعة على الملف.

لكن للدوال ثمناً: كل exists() أو get() داخل القاعدة قراءة مستند محسوبة على فاتورتك، ويُضاف زمنه إلى زمن الطلب، وهناك سقف لاستدعاءات الوصول في الطلب الواحد (عشرة للمستند المفرد). لهذا أبقينا قراءة المواضيع والردود بلا أي استدعاء: allow read: if isAuthed();، وحصرنا فحص الأدوار في مسارات الكتابة الأقل تكراراً بكثير. تفصيل هذا الاقتصاد في ضبط تكلفة Firestore.

الفخ الأول: claim بريد لا وجود له

حساب الإدارة عندنا معرَّف ببريده لا بدور مخزَّن، لأننا أردنا شبكة أمان لا تعتمد على مستند قد يُحرَّف. والكتابة الساذجة لذلك سطر يقارن request.auth.token.email بالبريد المعروف. المشكلة أن حسابات الجوال المسجّلة برسالة تحقّق لا تحمل claim باسم البريد في الرمز إطلاقاً — ليس فارغاً، بل غير موجود. والوصول المباشر إلى مفتاح غير موجود يُنتج خطأً يُفشِل القاعدة بأكملها.

هذا هو عطل «لا أستطيع النشر»: دالة الحظر تستدعي دالة بريد الإدارة، فتنهار عند كل مستخدمة سجّلت بالجوال، بينما تعمل قواعد القراءة لأنها لا تستدعيها. الصيغة الآمنة تفحص وجود المفتاح قبل قراءته:

return isAuthed()
  && ('email' in request.auth.token)
  && request.auth.token.email != null
  && request.auth.token.email.lower() == '<بريد الإدارة>';

ولاحظ lower(): المقارنة حسّاسة لحالة الأحرف افتراضياً، ومن يسجّل ببريد فيه حرف كبير يفقد صلاحيته بصمت. أما حسابات الجوال نفسها فلها مقال منفصل: رمز التحقّق بالجوال في Firebase.

الفخ الثاني: الحقل الاختياري الذي يُسقط القاعدة

نفس الفخّ ضربنا ثانية من جهة أخرى. دالتا التعليق والحظر كانتا تقرآن .data.suspended مباشرة من الملف الشخصي، لكن ملفات العضوات العاديات تُنشأ بلا هذين الحقلين — لا يُضافان إلا عند التعليق الفعلي. النتيجة: كل عضوة تقرأ المنتدى طبيعياً ولا تستطيع النشر ولا الردّ، لأن مسارات الكتابة وحدها تستدعي دالة الحظر. الصيغة الصحيحة قراءة آمنة بقيمة افتراضية:

get(/databases/$(database)/documents/profiles/$(request.auth.uid))
  .data.get('suspended', false) == true

وطبّقنا الشيء نفسه على البيانات القادمة في قاعدة الإنشاء: request.resource.data.get('banned', false) != true بدل الوصول المباشر. وهنا أسوأ ما في الفخّ: بيانات التسجيل لا ترسل الحقلين، فكان إنشاء الملف الشخصي يُرفض لكل عضوة جديدة وينجح لحساب الإدارة لأن الشرط ينتهي بـ || isAdmin() — فبقي العطل مخفيّاً عن كل اختبار أجريناه.

وأضفنا استثناءً مقصوداً: isBlocked() ترجع دائماً «غير محظور» للبريد الإداري، حتى لا يقفل أحد نفسه خارج لوحة الإدارة بتعليق سهويّ لا يستطيع بعده أحد رفعه.

قاعدة: كل حقل قد يكون غائباً يُقرأ بـ data.get('field', default)، وكل claim يُقرأ بعد 'x' in request.auth.token. خطأ التقييم رفضٌ كامل لا تحذير، ولن يصل العميل إلا permission-denied بلا سبب. أي وصول مباشر لمفتاح غير مضمون الوجود قنبلة موقوتة تنفجر عند شريحة مستخدمين لا تشبه حسابك الاختباري.

افصل القراءة والإنشاء والتعديل والحذف — ولا تكتب write

كلمة write ليست فعلاً واحداً؛ هي ثلاثة: إنشاء وتعديل وحذف. ومن يكتبها اختصاراً يمنح — دون أن يقصد غالباً — حقّ الحذف لكل من يملك حقّ الإضافة. في ملفنا لم تظهر إلا في موضعين مدروسين: إعدادات الموقع (allow write: if isAdmin();) لأن الفاعل واحد، والمواضيع المحفوظة لكل عضوة (allow read, write: if isSelf(userId);) لأن المسار كله مِلك صاحبته.

ما عدا ذلك فُصلت الأفعال، والمواضيع أوضح مثال: القراءة لأي مسجّلة، والإنشاء بشروط شكل مفصّلة، والحذف لصاحبة الموضوع أو الإدارة، أما التعديل فصار ستّة فروع لأن «تعديل موضوع» في الواقع أشياء متعدّدة يفعلها أشخاص مختلفون. أخطرها عدّاد المشاهدات: أي عضوة يجب أن تستطيع زيادة عدّاد موضوع لا تملكه، أي تعديل مستند غيرها. حصرناه بمفتاح واحد وقيمة واحدة:

request.resource.data.diff(resource.data).affectedKeys().hasOnly(['viewCount'])
&& request.resource.data.viewCount == resource.data.viewCount + 1

الشرطان معاً يقولان: لا تلمس حقلاً آخر، ولا تزد أكثر من واحد، ولا تُنقص. لو اكتفينا بالأول لأمكن وضع العدّاد على مليون؛ ولو منحنا update عاماً لأمكن تغيير عنوان أي موضوع أو تثبيته. وبنفس النمط سمحنا بزيادة عدّاد الردود مع وقت آخر ردّ (hasOnly(['replyCount','lastReplyAt']))، وبإنقاصه واحداً عند حذف ردّ.

ونفس المبدأ حكم دور المشرفة: لها تعليق عضوة مؤقتاً لا أكثر، فالقاعدة تشترط أن المفاتيح المتأثّرة لا تتجاوز ['suspended']، وأن الدور بعد التعديل مساوٍ للدور قبله، وأن حقل الحظر الدائم لم يتغيّر. بغير هذه الثلاثة كانت المشرفة تمنح نفسها دور الإدارة بتعديل واحد. وفي أجزاء مكتبة الصور ذهبنا للحدّ الأقصى: allow update: if false; — الجزء المرفوع يُحذف ويُعاد رفعه، ولا يُعدَّل.

تحقّق من شكل البيانات في القاعدة لا في العميل

الحدّ الذي تكتبه في الواجهة ليس أماناً بل تحسين تجربة: أي صاحب حساب حقيقي يفتح أدوات المتصفّح وينادي الحزمة مباشرة بأي حمولة يريدها، وخاصية maxlength لا تصله. لذلك انتقل كل حدّ حقيقي إلى القاعدة:

  • عنوان الموضوع: نصّ، طوله أكبر من صفر ولا يتجاوز أربعين حرفاً.
  • متن الموضوع: نصّ لا يتجاوز ثلاث مئة حرف، والردّ مئتين — مع سماح صريح بطول صفر في الردّ لأن الردّ قد يكون صورة بلا نصّ.
  • الصور: عضوة عادية صورة واحدة، والإدارة والمشرفة حتى خمسين — لأن قواعد Firestore لا تكرّر التحقّق على عناصر المصفوفة، فاكتفينا بالنوع والعدد واتّكلنا على سقف حجم المستند (ميغابايت واحد).
  • مصدر الصورة: إمّا معدوم، أو نصّ base64 يطابق ^data:image/(jpeg|jpg|png|webp|gif);base64,.* وحجمه أقل من 950 كيلوبايت (هامش مقصود تحت سقف المستند)، أو مرجع داخلي يطابق ^firestore:[A-Za-z0-9]{15,32}$، أو رابط https.
  • مكتبة الوسائط: نوع الملف يجب أن يطابق ^image/.* وأن لا يطابق ^image/svg.* — استثناء صريح لأن SVG وعاء نصّي قد يحمل سكربتاً. وعدد الأجزاء عدد صحيح لا يتجاوز مئتين للإدارة وعشرين للعضوة، وكل جزء لا يتجاوز 800 كيلوبايت.

وحين يكون شكل المستند معروفاً كلياً، فالأقوى قائمة بيضاء للحقول لا شروط على حقول بعينها. عدّاد رسائل التحقّق اليومي مثال مكتمل: الإنشاء مسموح فقط إذا كان request.resource.data.keys().hasOnly(['count', 'day']) والقيمة الابتدائية واحد، والتعديل فقط لزيادة العدّاد واحداً. لا تصفير، ولا حقول إضافية، ولا خفض. النتيجة تصميم «آمن بالفشل»: أقصى ما يستطيعه مُسيء تعطيل الدخول بالجوال ليوم بتضخيم العدّاد، لا تجاوزه ولا تحميلنا فاتورة رسائل.

وفي إشعارات الردود اجتمع النوعان: النوع محصور بقائمة بيضاء ['reply_topic', 'reply_reply']، والحقول الاختيارية محدودة الطول (معرّف الموضوع أربعون حرفاً، وعنوانه مئتان، واسم المُرسِلة ثمانون)، ولا انتحال لأن حقل المُرسِلة يجب أن يساوي هُويّتها، ولا إشعار لنفسها لأن مسار المستهدَف يجب أن يخالف هُويّتها.

تحذير: التحقّق في العميل تجربة استخدام، والتحقّق في القاعدة أمان. اكتب الأول لتُظهر الخطأ فوراً وبلغة مفهومة، واكتب الثاني لأنه الوحيد الذي لا يستطيع أحد تخطّيه. وإن اضطررت للاختيار بينهما، فالقاعدة هي الحقيقة والعميل مجرّد اقتراح.

القاعدة تُقيَّم لكل مستند لا لكل استعلام

هذه الفكرة سببُ معظم الأسئلة الحائرة: «القاعدة صحيحة والمستند مقروء، ومع ذلك يفشل الاستعلام». السبب أن محرّك الأمان لا يجلب النتائج ثم يُصفّيها. هو يسأل سؤالاً واحداً قبل قراءة أي شيء: هل يضمن شكل هذا الاستعلام أن كل مستند قد يعيده مسموح بالقواعد؟ إن لم يضمن، يفشل الاستعلام كاملاً — لا نتائج جزئية ولا تصفية صامتة.

ولهذا فرّقنا صراحةً بين get (قراءة مستند مفرد) وlist (سرد المجموعة) في مجموعتين حسّاستين. الأولى قائمة البُرود المحظورة من إعادة التسجيل:

allow get: if isAuthed();
allow list: if isAdmin();

الفرق ليس شكلياً: البرود بيانات شخصية، وسردها يعني تمكين أي مسجّلة من حصد قائمة كاملة. لكن التطبيق يحتاج فحص بريد واحد عند التسجيل. لو كتب العميل ذلك استعلاماً بشرط مساواة على حقل البريد لفشل بـ permission-denied رغم أن المستند نفسه مقروء بالقاعدة. فعُدنا وصمّمنا البيانات لتطابق القاعدة لا العكس: معرّف المستند نفسه صار مفتاحاً مشتقّاً من البريد، والعميل يقرأه بـ doc(...) مباشرة. النتيجة ثلاثة أرباح في قرار واحد: لا استعلام، وقراءة واحدة بدل مسح، وخصوصية محفوظة لأن السرد ممنوع.

نفس الحلّ تكرّر في عدّاد الرسائل لكل رقم جوال: allow get: if true; لأن الفحص يجري قبل تسجيل الدخول فلا هُويّة بعد، مع allow list: if isAdmin(); حتى لا تتحوّل المجموعة إلى قائمة أرقام جوّال قابلة للتعداد. القاعدة العملية التي خرجنا بها: كل شرط في القاعدة يعتمد على قيمة حقل يحتاج نظيراً صريحاً في الاستعلام. إن كانت قاعدتك resource.data.authorId == request.auth.uid فاستعلامك يجب أن يحمل شرط المساواة على نفس الحقل حرفياً، وإلا رُفض.

ورسائل الإدارة الخاصة مثال على أثر ذلك في التصميم: الوثيقة إما بثّ للجميع (بلا حقل مستهدَف) أو فردية (بحقل مستهدَف واسم صريح)، والجمع بينهما ممنوع في قاعدة الإنشاء. سبب المنع تجربة سابقة: النمط القديم كان يُنشئ وثيقة بثّ منفصلة لكل عضوة، فتحوّل إعلان واحد إلى مئات الكتابات ومئات القراءات. تغيير القاعدة هنا لم يكن أمناً فقط، كان ضبط تكلفة.

العام مقابل المقيّد: مثال ملموس

أكثر ما يُفتح بالخطأ هو «المحتوى العام». المطلوب عندنا كان صغيراً جداً: مفتاح واحد يقرأه المتصفّح قبل تسجيل الدخول ليعرف هل يُظهر زرّ الدخول بالجوال، وإعدادات بنرات تظهر للزائرات وللمسجّلات معاً. الإغراء هنا أن تكتب allow read: if true; على مجموعة الإعدادات وتنتهي. ما كتبناه فعلاً:

match /settings/{docId} {
  allow read: if isAuthed() || docId == 'auth' || docId == 'ads';
  allow write: if isAdmin();
}

الفرق جذري. هنا الانفتاح محصور بمعرّفَي مستندين بالاسم، والباقي يحتاج تسجيل دخول، والكتابة للإدارة وحدها. أهمّ ما يحمينا في هذه الصيغة هو المستقبل: أي وثيقة إعدادات نضيفها بعد سنة — مفاتيح خدمة، حدود داخلية، إعدادات تجريبية — تبدأ مقيّدة تلقائياً بلا أن يتذكّر أحد شيئاً. مع if true على المجموعة كانت ستُنشر مفتوحة في اللحظة نفسها.

في الطرف المقابل، بيانات المستخدمة الصحّية أضيق مسار في الملف: القراءة لصاحبتها أو للإدارة، والإنشاء لصاحبتها وحدها، والتعديل لصاحبتها أو للإدارة، والحذف للإدارة فقط — ودور المشرفة يقرأ قائمة العضوات ولا يعدّل بياناتهنّ الشخصية إطلاقاً. والبلاغات معكوسة الاتجاه: العضوة تُنشئ بلاغاً بشرط أن يكون المُبلِّغ هي نفسها، ولا تقرأ البلاغات أصلاً؛ القراءة للإدارة والمشرفة. وللملفات المرفوعة منطق مشابه بأدوات مختلفة، مفصّل في قواعد Firebase Storage.

الملف داخل المشروع هو المصدر الوحيد للحقيقة

هذه قاعدة تنظيمية لا تقنية، وثمن تجاهلها أغلى من أي خطأ في الصيغة. القواعد التي تُحرَّر من واجهة الكونسول تعيش في مكان لا يراه git: لا تاريخ، ولا مقارنة فروق، ولا مراجعة، ولا تفسير لسبب التغيير. وفي أول نشر من سطر الأوامر يُكتب عليها ملفُ المشروع فتختفي بلا إشعار. عندنا مشروع قديم بقيت قواعده في الكونسول فقط، وحين احتجنا معرفة «لماذا صار هذا المسار مفتوحاً؟» لم يكن هناك جواب — لا سطر يقول من غيّر ولا متى ولا لأي سبب.

لذلك اعتمدنا أن الملف الذي يشير إليه firebase.json داخل مجلّد المشروع هو المصدر الوحيد، والنشر منه فقط، والتعليقات العربية داخله ليست ترفاً: أطول تعليق في ملفنا يشرح فخّ claim البريد وفخّ الحقل الناقص، لأن السطر الآمن يبدو «مبالَغاً فيه» لمن لم يعش العطل، وأول من يجده سيبسّطه ويعيد الكارثة. مراجعة الفروق في git هي ما جعل تحديد الخطأ ممكناً أصلاً.

وهناك تفصيل معماري يُنسى: حزمة الإدارة في السحابة (Admin SDK) تتجاوز هذه القواعد كلياً. الوظائف السحابية تكتب كما تشاء. لذلك المجموعات التي لا يجوز لعميل أن يكتبها كُتبت بصراحة تامة: سجلّ الإرسال الجماعي وسجلّ تشغيل التذكيرات فيهما allow create, update: if false;. القاعدة هنا ليست تعطيلاً للميزة، بل توثيق نيّة: لا عميل يكتب هنا أبداً، والكتابة من الخادم وحده. وقبل أي نشر، الاختبار في المحاكي المحلي أرخص من الاعتذار للمستخدمات — وأهم حالتين نختبرهما دائماً: حساب بلا claim بريد، وملف شخصي بلا حقول اختيارية. هاتان بالضبط هما الحالتان التي أسقطتا الإنتاج.

أسئلة شائعة

لماذا لا تخبرني رسالة الخطأ أيّ شرط سقط؟ لأن الغموض مقصود أمنياً: رسالة تفصيلية تعني إخبار المُهاجم بخريطة قواعدك. الحلّ ليس تحسين الرسالة بل الاختبار في المحاكي أو ساحة تجربة القواعد في الكونسول، حيث تُشغّل الطلب بهُويّة مُحدَّدة وترى الشرط الذي رجع خطأً. تذكّر التمييز الحاسم: «الشرط رجع كذباً» و«الشرط رمى خطأً» يعطيان للعميل نفس الرسالة تماماً، وطريقة إصلاحهما مختلفة كلياً.

أخزّن الدور في claim مخصّص أم في مستند؟ جرّبنا الاثنين واستقرّينا على مزيج. البريد الإداري يأتي من الرمز نفسه: تقييمه مجاني ولا يحتاج قراءة، وهو شبكة الأمان التي لا تُحظر أبداً. أما الأدوار المتغيّرة فهي في مستند الملف الشخصي لأن تغييرها يسري في الطلب التالي مباشرة، بينما الـ claim المخصّص لا يتحدّث إلا بتجديد الرمز — وهذا فرق مؤلم عند سحب صلاحية من شخص الآن.

هل تكفي القواعد لمنع الإساءة؟ لا. القواعد تمنع الوصول غير المصرّح به، ولا تمنع الاستهلاك المفرط ممّن يملك التصريح. من يستطيع القراءة يستطيع تكرارها ألف مرة. الحماية هنا تصميمية: عدّادات يومية بحدود، وأسقف أحجام، واستعلامات مصمّمة لتقرأ أقلّ، ومنع السرد على المجموعات الحسّاسة.

هل أختبر بحساب الإدارة؟ هذا بالضبط الخطأ الذي أخفى عنّا عطلين كاملين. أغلب الشروط تنتهي بـ «أو المديرة»، فحساب الإدارة ينجح دائماً ويخفي كل خطأ في الفروع الأخرى. اختبر بأدنى الحسابات صلاحية، وبكل طريقة تسجيل تدعمها فعلاً، وبملف شخصي جديد لا ملف قديم مكتمل الحقول. وإن كان تسجيل الدخول نفسه هو ما يفشل عندك، فالتشخيص المفصّل في فشل تسجيل الدخول بقوقل على أندرويد.

خلاصة

قواعد الإنتاج ليست نسخة أطول من قواعد الاختبار، بل طريقة تفكير مختلفة. ما نفّذناه يتكرّر في أي مشروع:

  1. ابدأ بالمنع، وأغلق الملف ببلوك يرفض كل مسار غير مصرَّح به — ليبدأ كل جديد مغلقاً.
  2. اجمع منطق الهُويّة والأدوار في دوال صغيرة مُسمّاة، وأبقِ مسارات القراءة الساخنة بلا استدعاءات مكلفة.
  3. لا تقرأ حقلاً أو claim قد يكون غائباً إلا بقراءة آمنة أو فحص وجود مسبق؛ الخطأ في التقييم = رفض صامت.
  4. افصل القراءة والإنشاء والتعديل والحذف، واستخدم المفاتيح المتأثّرة لتقييد التعديل بحقل واحد وقيمة واحدة.
  5. ضع كل حدّ حقيقي — الأنواع والأطوال والقوائم البيضاء — في القاعدة، وعُدّ ما في العميل تحسين تجربة لا أماناً.
  6. فرّق بين قراءة المستند وسرد المجموعة، وصمّم معرّفات بياناتك لتقرأ مباشرة بلا استعلام.
  7. اجعل الملف داخل المستودع المصدر الوحيد، وعلّق فيه على كل سطر «غريب» يحمي من عطل عشته.

وأصدق مقياس لقواعدك ليس أنها تسمح لك بالعمل، بل أنها تمنعك أنت حين تحاول ما لا يحقّ لك.

نشرت قواعدك ولا تظهر تعديلاتك للمستخدمين؟ المشكلة غالباً ليست في القواعد: إدارة إصدارات Service Worker →