Skip to content

Commit fbe4284

Browse files
committed
fix: classify unknown queue permission denials with runtime diagnostics
1 parent ef201a6 commit fbe4284

6 files changed

Lines changed: 230 additions & 11 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,12 +212,14 @@ These constraints are enforced in `firestore.rules` and validated by `test/rules
212212
### Production Firestore Rules Runbook
213213
1. Authenticate Firebase CLI:
214214
- `npx firebase login`
215-
2. Deploy production Firestore rules:
215+
2. Verify project/database deploy targets:
216+
- `npm run rules:target:check`
217+
3. Deploy production Firestore rules:
216218
- `npm run rules:deploy:prod`
217-
3. Validate production owner view:
219+
4. Validate production owner view:
218220
- Sign in as owner and open the pantry/unknown queue section.
219221
- Confirm no `Unknown ingredient queue access denied` banner appears.
220-
4. Optional deploy diagnostics:
222+
5. Optional deploy diagnostics:
221223
- `npm run rules:deploy:prod:dry`
222224

223225
## GitHub-Vercel Sync Workflow

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"lint": "tsc --noEmit",
1313
"unit:test": "node --import tsx test/unit/run.ts",
1414
"rules:test": "node test/rules/check-java.mjs && firebase emulators:exec --only firestore --project demo-rasoi-planner \"tsx test/rules/run.ts\"",
15+
"rules:target:check": "node scripts/print-firestore-rules-target.mjs",
1516
"rules:deploy:prod": "npx firebase deploy --only firestore:rules --project gen-lang-client-0862152879",
1617
"rules:deploy:prod:dry": "npx firebase deploy --only firestore:rules --project gen-lang-client-0862152879 --debug",
1718
"e2e": "node test/e2e/run.mjs",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { readFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
async function readJsonFile(filePath) {
5+
const raw = await readFile(filePath, 'utf-8');
6+
return JSON.parse(raw);
7+
}
8+
9+
function getFirestoreTargets(firebaseConfig) {
10+
const firestoreConfig = firebaseConfig.firestore;
11+
if (Array.isArray(firestoreConfig)) {
12+
return firestoreConfig;
13+
}
14+
15+
if (firestoreConfig && typeof firestoreConfig === 'object') {
16+
return [{ database: '(default)', ...firestoreConfig }];
17+
}
18+
19+
return [];
20+
}
21+
22+
async function main() {
23+
const rootDir = process.cwd();
24+
const appConfigPath = path.join(rootDir, 'firebase-applet-config.json');
25+
const firebaseJsonPath = path.join(rootDir, 'firebase.json');
26+
27+
const appConfig = await readJsonFile(appConfigPath);
28+
const firebaseConfig = await readJsonFile(firebaseJsonPath);
29+
30+
const projectId = String(appConfig.projectId ?? '').trim();
31+
const databaseId = String(appConfig.firestoreDatabaseId ?? '').trim();
32+
const targets = getFirestoreTargets(firebaseConfig);
33+
const databases = targets
34+
.map((target) => String(target.database ?? '').trim())
35+
.filter((value) => value.length > 0);
36+
37+
const hasDefaultTarget = databases.includes('(default)');
38+
const hasNamedTarget = databases.includes(databaseId);
39+
40+
console.log('Firestore deploy target verification');
41+
console.log(`- projectId: ${projectId}`);
42+
console.log(`- app databaseId: ${databaseId}`);
43+
console.log(`- firebase.json targets: ${databases.join(', ') || '(none)'}`);
44+
console.log('- recommended deploy command:');
45+
console.log(` npx firebase deploy --only firestore:rules --project ${projectId}`);
46+
47+
if (!hasDefaultTarget || !hasNamedTarget) {
48+
console.error('Firestore target mismatch detected in firebase.json.');
49+
process.exitCode = 1;
50+
}
51+
}
52+
53+
main().catch((error) => {
54+
console.error('Failed to verify Firestore deploy targets.', error);
55+
process.exitCode = 1;
56+
});

src/App.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { onAuthStateChanged, User as FirebaseUser } from 'firebase/auth';
44
import {
55
collection,
66
doc,
7+
getDoc,
78
onSnapshot,
89
orderBy,
910
query,
@@ -27,8 +28,12 @@ import {
2728
import { upsertMealField } from './services/mealService';
2829
import { HouseholdData, resolveOrCreateHousehold } from './services/householdService';
2930
import { getAppCopy } from './i18n/copy';
31+
import firebaseConfig from '../firebase-applet-config.json';
3032
import {
33+
buildUnknownQueueTargetFingerprint,
34+
classifyHouseholdMembershipProbe,
3135
getUnknownQueueLoadErrorMessage,
36+
HouseholdMembershipProbeResult,
3237
isFirestoreFailedPreconditionError,
3338
sortUnknownIngredientQueueItemsByCreatedAt,
3439
toFirestoreListenerErrorInfo,
@@ -200,6 +205,36 @@ export default function App() {
200205
markInitialViewReady();
201206
};
202207

208+
const unknownQueuePath = `households/${resolved.householdId}/unknownIngredientQueue`;
209+
const targetFingerprint = buildUnknownQueueTargetFingerprint({
210+
projectId: firebaseConfig.projectId,
211+
databaseId: firebaseConfig.firestoreDatabaseId,
212+
householdId: resolved.householdId,
213+
});
214+
215+
const membershipProbeSnapshot = await getDoc(doc(db, 'households', resolved.householdId));
216+
const membershipProbeData = membershipProbeSnapshot.exists()
217+
? (membershipProbeSnapshot.data() as HouseholdData)
218+
: null;
219+
const membershipProbeResult: HouseholdMembershipProbeResult = classifyHouseholdMembershipProbe({
220+
householdExists: membershipProbeSnapshot.exists(),
221+
householdOwnerId: membershipProbeData?.ownerId ?? null,
222+
householdCookEmail: membershipProbeData?.cookEmail ?? null,
223+
userUid: user.uid,
224+
userEmail: user.email ?? null,
225+
});
226+
227+
console.info('unknown_queue_runtime_target', {
228+
projectId: firebaseConfig.projectId,
229+
databaseId: firebaseConfig.firestoreDatabaseId,
230+
householdId: resolved.householdId,
231+
uid: user.uid,
232+
email: user.email ?? null,
233+
path: unknownQueuePath,
234+
targetFingerprint,
235+
membershipProbeResult,
236+
});
237+
203238
const subscribeUnknownQueueFallback = (): void => {
204239
if (unknownQueueFallbackUnsub !== null) {
205240
return;
@@ -221,8 +256,15 @@ export default function App() {
221256
householdId: resolved.householdId,
222257
code: parsedError.code,
223258
message: parsedError.message,
259+
projectId: firebaseConfig.projectId,
260+
databaseId: firebaseConfig.firestoreDatabaseId,
261+
uid: user.uid,
262+
email: user.email ?? null,
263+
path: unknownQueuePath,
264+
targetFingerprint,
265+
membershipProbeResult,
224266
});
225-
setUiFeedback({ kind: 'error', message: getUnknownQueueLoadErrorMessage(parsedError) });
267+
setUiFeedback({ kind: 'error', message: getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult) });
226268
hasLoadedUnknownQueue = true;
227269
markInitialViewReady();
228270
},
@@ -245,19 +287,26 @@ export default function App() {
245287
householdId: resolved.householdId,
246288
code: parsedError.code,
247289
message: parsedError.message,
290+
projectId: firebaseConfig.projectId,
291+
databaseId: firebaseConfig.firestoreDatabaseId,
292+
uid: user.uid,
293+
email: user.email ?? null,
294+
path: unknownQueuePath,
295+
targetFingerprint,
296+
membershipProbeResult,
248297
});
249298

250299
if (isFirestoreFailedPreconditionError(parsedError)) {
251300
if (unknownQueueUnsub !== null) {
252301
unknownQueueUnsub();
253302
unknownQueueUnsub = null;
254303
}
255-
setUiFeedback({ kind: 'error', message: getUnknownQueueLoadErrorMessage(parsedError) });
304+
setUiFeedback({ kind: 'error', message: getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult) });
256305
subscribeUnknownQueueFallback();
257306
return;
258307
}
259308

260-
setUiFeedback({ kind: 'error', message: getUnknownQueueLoadErrorMessage(parsedError) });
309+
setUiFeedback({ kind: 'error', message: getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult) });
261310
hasLoadedUnknownQueue = true;
262311
markInitialViewReady();
263312
},

src/utils/unknownQueue.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ export interface FirestoreListenerErrorInfo {
66
name: string | null;
77
}
88

9+
export interface UnknownQueueTargetFingerprintInput {
10+
databaseId: string;
11+
householdId: string;
12+
projectId: string;
13+
}
14+
15+
export interface HouseholdMembershipProbeInput {
16+
householdCookEmail: string | null;
17+
householdExists: boolean;
18+
householdOwnerId: string | null;
19+
userEmail: string | null;
20+
userUid: string;
21+
}
22+
23+
export type HouseholdMembershipProbeResult = 'owner' | 'cook' | 'non-member' | 'household-missing';
24+
925
function toRecord(value: unknown): Record<string, unknown> | null {
1026
if (typeof value !== 'object' || value === null) {
1127
return null;
@@ -39,9 +55,41 @@ export function isFirestorePermissionDeniedError(error: FirestoreListenerErrorIn
3955
return error.code === 'permission-denied';
4056
}
4157

42-
export function getUnknownQueueLoadErrorMessage(error: FirestoreListenerErrorInfo): string {
58+
function normalizeEmail(value: string | null): string {
59+
return value === null ? '' : value.trim().toLowerCase();
60+
}
61+
62+
export function classifyHouseholdMembershipProbe(input: HouseholdMembershipProbeInput): HouseholdMembershipProbeResult {
63+
if (!input.householdExists) {
64+
return 'household-missing';
65+
}
66+
67+
if (input.householdOwnerId === input.userUid) {
68+
return 'owner';
69+
}
70+
71+
const normalizedCookEmail = normalizeEmail(input.householdCookEmail);
72+
const normalizedUserEmail = normalizeEmail(input.userEmail);
73+
if (normalizedCookEmail.length > 0 && normalizedCookEmail === normalizedUserEmail) {
74+
return 'cook';
75+
}
76+
77+
return 'non-member';
78+
}
79+
80+
export function buildUnknownQueueTargetFingerprint(input: UnknownQueueTargetFingerprintInput): string {
81+
return `${input.projectId}/${input.databaseId}/households/${input.householdId}/unknownIngredientQueue`;
82+
}
83+
84+
export function getUnknownQueueLoadErrorMessage(
85+
error: FirestoreListenerErrorInfo,
86+
membershipProbeResult: HouseholdMembershipProbeResult | null,
87+
): string {
4388
if (isFirestorePermissionDeniedError(error)) {
44-
return 'Unknown ingredient queue access denied. Deploy latest Firestore rules and retry.';
89+
if (membershipProbeResult === 'non-member' || membershipProbeResult === 'household-missing') {
90+
return 'Unknown ingredient queue access denied. Household membership mismatch suspected.';
91+
}
92+
return 'Unknown ingredient queue access denied. Firestore target mismatch suspected. Verify project/database rules deployment.';
4593
}
4694

4795
if (isFirestoreFailedPreconditionError(error)) {

test/unit/run.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { buildPantryLog } from '../../src/services/logService';
55
import { sanitizeFirestorePayload } from '../../src/utils/firestorePayload';
66
import { getIngredientNativeContextLabel, resolveIngredientVisual } from '../../src/utils/ingredientVisuals';
77
import {
8+
buildUnknownQueueTargetFingerprint,
9+
classifyHouseholdMembershipProbe,
810
getUnknownQueueLoadErrorMessage,
911
isFirestoreFailedPreconditionError,
1012
isFirestorePermissionDeniedError,
@@ -347,20 +349,30 @@ function testUnknownQueueErrorParsingAndMessaging(): void {
347349
name: 'FirebaseError',
348350
});
349351
assert.equal(isFirestorePermissionDeniedError(permissionDenied), true);
350-
assert.equal(getUnknownQueueLoadErrorMessage(permissionDenied), 'Unknown ingredient queue access denied. Deploy latest Firestore rules and retry.');
352+
assert.equal(
353+
getUnknownQueueLoadErrorMessage(permissionDenied, 'owner'),
354+
'Unknown ingredient queue access denied. Firestore target mismatch suspected. Verify project/database rules deployment.',
355+
);
356+
assert.equal(
357+
getUnknownQueueLoadErrorMessage(permissionDenied, 'non-member'),
358+
'Unknown ingredient queue access denied. Household membership mismatch suspected.',
359+
);
351360

352361
const failedPrecondition = toFirestoreListenerErrorInfo({
353362
code: 'failed-precondition',
354363
message: 'The query requires an index.',
355364
name: 'FirebaseError',
356365
});
357366
assert.equal(isFirestoreFailedPreconditionError(failedPrecondition), true);
358-
assert.equal(getUnknownQueueLoadErrorMessage(failedPrecondition), 'Unknown ingredient queue index is missing. Showing fallback order while index is provisioned.');
367+
assert.equal(
368+
getUnknownQueueLoadErrorMessage(failedPrecondition, 'owner'),
369+
'Unknown ingredient queue index is missing. Showing fallback order while index is provisioned.',
370+
);
359371

360372
const unknownError = toFirestoreListenerErrorInfo(new Error('boom'));
361373
assert.equal(isFirestoreFailedPreconditionError(unknownError), false);
362374
assert.equal(isFirestorePermissionDeniedError(unknownError), false);
363-
assert.equal(getUnknownQueueLoadErrorMessage(unknownError), 'Failed to load unknown ingredient queue.');
375+
assert.equal(getUnknownQueueLoadErrorMessage(unknownError, null), 'Failed to load unknown ingredient queue.');
364376
}
365377

366378
function testUnknownQueueFallbackSortOrder(): void {
@@ -397,6 +409,56 @@ function testUnknownQueueFallbackSortOrder(): void {
397409
assert.deepEqual(sorted.map((item) => item.id), ['queue-1', 'queue-2', 'queue-3']);
398410
}
399411

412+
function testUnknownQueueTargetFingerprintAndMembershipProbe(): void {
413+
const fingerprint = buildUnknownQueueTargetFingerprint({
414+
projectId: 'project-x',
415+
databaseId: 'db-y',
416+
householdId: 'house-z',
417+
});
418+
assert.equal(fingerprint, 'project-x/db-y/households/house-z/unknownIngredientQueue');
419+
420+
assert.equal(
421+
classifyHouseholdMembershipProbe({
422+
householdExists: true,
423+
householdOwnerId: 'owner-1',
424+
householdCookEmail: 'cook@example.com',
425+
userUid: 'owner-1',
426+
userEmail: 'owner@example.com',
427+
}),
428+
'owner',
429+
);
430+
assert.equal(
431+
classifyHouseholdMembershipProbe({
432+
householdExists: true,
433+
householdOwnerId: 'owner-1',
434+
householdCookEmail: 'cook@example.com',
435+
userUid: 'cook-uid',
436+
userEmail: 'cook@example.com',
437+
}),
438+
'cook',
439+
);
440+
assert.equal(
441+
classifyHouseholdMembershipProbe({
442+
householdExists: true,
443+
householdOwnerId: 'owner-1',
444+
householdCookEmail: 'cook@example.com',
445+
userUid: 'intruder',
446+
userEmail: 'intruder@example.com',
447+
}),
448+
'non-member',
449+
);
450+
assert.equal(
451+
classifyHouseholdMembershipProbe({
452+
householdExists: false,
453+
householdOwnerId: null,
454+
householdCookEmail: null,
455+
userUid: 'owner-1',
456+
userEmail: 'owner@example.com',
457+
}),
458+
'household-missing',
459+
);
460+
}
461+
400462
function run(): void {
401463
testPantryCategoryNormalization();
402464
testPantryCategoryLabels();
@@ -419,6 +481,7 @@ function run(): void {
419481
testIngredientVisualCategoryFallback();
420482
testUnknownQueueErrorParsingAndMessaging();
421483
testUnknownQueueFallbackSortOrder();
484+
testUnknownQueueTargetFingerprintAndMembershipProbe();
422485
console.log('All unit tests passed.');
423486
}
424487

0 commit comments

Comments
 (0)