Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ These entries are implementation evidence, not a production risk-acceptance deci
| DATA-001A6 / registration snapshot-processing failure containment | DATA-001A6 [#566](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/566) separately catches without binding every exceptional exact-UID `QuerySnapshot.forEach` and owned-document reference, ID, data, or retained-field projection outcome. It returns only fixed `unavailable / Registration data could not be loaded.` before event-ID derivation or event access. Synthetic iterator, reference-path, projection-time `data()`, and retained-field failures prove no caught-value getter, coercion, serialization, formatting hook, console/Firebase logger, event read, or later-document projection is reached while the exact query and successful/empty behavior stay unchanged. | This source is not deployed or live. It does not contain exceptional event-ID derivation, event collection/document/get/snapshot/projection work, or later registration sorting; change stored data, field retrieval, or response shape; add retries; or prove Firebase logging outside application code. Complete a protected Function deployment/readback and made-up account check before describing the boundary as live; harden later event/sort stages separately. |
| DATA-001A7 / registration event-acquisition failure containment | DATA-001A7 [#575](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/575) catches without binding every exceptional event collection, document, or `get` acquisition outcome and returns only fixed `unavailable / Registration data could not be loaded.` before a successful event snapshot or field projection for the failing ID. Synthetic stage failures prove no caught-value getter, coercion, serialization, formatting hook, console/Firebase logger, event-data read, or partial response is reached while App Check, Auth, the exact-UID registration query, registration projection, event lookup concurrency, successful/empty behavior, and created-time sort stay unchanged. | This source is not deployed or live. It does not contain exceptional event-ID derivation, event snapshot `exists`, `data()`, retained-field projection, concurrent sibling cancellation/order, or later registration sorting; change full Firestore document retrieval, stored data, response shape, or permissions; add retries; or prove platform logging outside application code. Complete a protected Function deployment/readback and made-up account check before describing the fixed boundary as live; harden later event projection and sort stages separately. |
| DATA-001A8 / registration event snapshot-processing failure containment | DATA-001A8 [#579](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/579) separately catches without binding every exceptional acquired event snapshot `exists`, `data()`, or retained `slug`, `title`, `startAt`, and `location` projection outcome. It returns only fixed `unavailable / Registration data could not be loaded.` and no partial callable response. Synthetic snapshot and field failures prove no caught-value getter, coercion, serialization, formatting hook, console/Firebase logger, or later registration sort is reached while App Check, Auth, the exact-UID query, runner minimization, A7 acquisition, missing-event success, valid event projection, lookup concurrency, successful/empty behavior, and created-time sort stay unchanged. | This source is not deployed or live. It does not validate event IDs or malformed nonthrowing event values; cancel or serialize concurrent sibling reads; contain event-ID derivation, later registration sorting, response/platform serialization, or platform logging outside application code; change full Firestore document retrieval, stored data, response shape, permissions, or retries; or prove live behavior. Complete a protected Function deployment/readback and made-up account check before describing the fixed boundary as live; harden remaining derivation/sort/serialization residuals separately. |
| DATA-001A9 / registration-sort failure containment | DATA-001A9 [#598](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/598) catches without binding every exceptional created-time comparison outcome in the final registration sort after event work completes and returns only fixed `unavailable / Registration data could not be loaded.` with no partial callable response. Synthetic `_seconds` access and numeric-coercion failures prove no caught failure-value inspection, formatting, serialization, logging, or raw escape while App Check, Auth, the exact-UID query, runner minimization, A5–A8 boundaries, event lookup concurrency, successful and empty behavior, and descending registration-created ordering stay unchanged. | This source is not deployed or live. It does not validate malformed-but-nonthrowing timestamps or event IDs; alter fallback-to-zero or tie behavior; contain event-ID derivation or response/platform serialization; cancel or reorder already completed event reads; change stored data, response shape, permissions, or retries; prove platform logging outside application code; or prove live behavior. Complete a protected Function deployment/readback and made-up account check before describing the fixed boundary as live. |
| RISK-026, RISK-036 | #135 merged through PR #138 as `9eafab1217aff7058c42240aaba72d7b93f8ed24`, replacing automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. Post-merge staging and synthetic production probes failed closed before authentication or mutation, and published neither Firebase nor Pages. | #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. |
| RISK-039 | The #118 source slice uses an empty authenticated callable request, bounded Firebase Auth identity fields, one transactional create-only helper shared with signup, constant responses, generic failures, and a UI that hides Edit until setup and the Rules-protected read succeed. Signup and recovery never change custom claims; browser profile creation remains denied. | Source review/merge is not deployment. Under #105, deploy the exact #100 Rules plus both `createMemberOnSignUp` and `ensureMemberProfile` before the website, prove App Check policy, use a synthetic staged account, verify rollback, then record website, Function, Rules, and live behavior separately. Never repair a real profile manually. |
| RISK-040 | AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged as `46557c7`: account creation returns `accepted` or `unavailable` without exposing provider details. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) merged as `23bca8c8`: My Account makes no false “sent” claim, blocks rapid repeats, and applies the same 60-second browser cooldown after either outcome. Its protected release run `29252492614` stopped before build because the required public App Check key was absent, so neither frontend revision is published. AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks one byte-equivalent password-reset request result after provider success or failure. AUTH-MAIL-002C2 [#194](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/194) tracks the source-only `/auth/action` verification path: the initial capability suppresses Sentry/App Check, the page removes native and router query/fragment state, a scanner-style page load makes no action-code check/apply and no account mutation, and one deliberate action requires provider `VERIFY_EMAIL` before apply. Fixed results expose no email, code, raw provider error, account identity, or provider-directed navigation. Synthetic tests use mocks and canary values only. | Publish and verify each exact frontend revision separately. The existing #99 Pages bridge briefly uses tab-local session storage for the return route and deletes it before React; a failed root load can leave it until tab close, while direct-rewrite hosting avoids that residual. #194 adds no storage write. #118 profile source is merged but live behavior is unproven. Keep delivery, Spam, DNS, templates, provider handler choice, and private Firebase email-enumeration-protection readback under #119. Firebase uses one custom handler for verification, password reset, and email recovery; never point its global action URL at the verification-only #194 route until every enabled mode is safely handled, or keep the default multi-mode handler. Accepted requests do not prove delivery, browser cooldowns are not abuse controls, Auth verification does not grant membership, and the Firestore verification mirror remains unfinished AUTH-001 work. |
Expand Down
18 changes: 13 additions & 5 deletions functions/listMyRegistrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,19 @@ exports.listMyRegistrations = functions.https.onCall(async (data, context) => {
}
}));

const registrations = Array.from(results.values()).sort((a, b) => {
const ta = a.createdAt?._seconds || 0;
const tb = b.createdAt?._seconds || 0;
return tb - ta;
});
let registrations;
try {
registrations = Array.from(results.values()).sort((a, b) => {
const ta = a.createdAt?._seconds || 0;
const tb = b.createdAt?._seconds || 0;
return tb - ta;
});
} catch {
throw new functions.https.HttpsError(
'unavailable',
'Registration data could not be loaded.',
);
}

return { registrations, events };
});
213 changes: 213 additions & 0 deletions functions/listMyRegistrations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1143,3 +1143,216 @@ describe('DATA-001A8 registration event snapshot/projection failure boundary', (
expect(admin.__eventSnapshotStages()).toEqual(['exists:synthetic-event']);
});
});

describe('DATA-001A9 registration-sort failure boundary', () => {
beforeEach(() => {
admin.__reset();
admin.firestore.mockClear();
firebaseFunctions.__resetLoggerCalls();
requireAppCheck.mockReset();
});

test.each([
['nested seconds access', 1, 0],
['numeric coercion', 2, 1],
])('maps hostile %s failure to one fixed result', async (
stage,
expectedSecondsReads,
expectedCoercionReads,
) => {
let hostileReads = 0;
const hostileFailureTarget = Object.create(null);
Object.defineProperty(hostileFailureTarget, inspect.custom, {
value: () => {
hostileReads += 1;
throw new Error('hostile registration-sort formatter was inspected');
},
});
const hostileFailure = new Proxy(hostileFailureTarget, {
get() {
hostileReads += 1;
throw new Error('hostile registration-sort getter was inspected');
},
getOwnPropertyDescriptor() {
hostileReads += 1;
throw new Error('hostile registration-sort descriptor was inspected');
},
ownKeys() {
hostileReads += 1;
throw new Error('hostile registration-sort keys were inspected');
},
});
let secondsReads = 0;
let coercionReads = 0;
const coercibleSeconds = Object.create(null);
Object.defineProperty(coercibleSeconds, Symbol.toPrimitive, {
value: () => {
coercionReads += 1;
throw hostileFailure;
},
});
const documents = [
registrationDocument({
createdSeconds: 1_800_000_010,
eventId: 'synthetic-sort-event',
id: 'synthetic-sort-registration-a',
}),
registrationDocument({
createdSeconds: 1_800_000_020,
eventId: 'synthetic-sort-event',
id: 'synthetic-sort-registration-b',
}),
];
documents.forEach((document) => {
const createdAt = document.data().createdAt;
document.data.mockClear();
Object.defineProperty(createdAt, '_seconds', {
configurable: true,
get() {
secondsReads += 1;
if (stage === 'nested seconds access') throw hostileFailure;
return coercibleSeconds;
},
});
});
admin.__seedRegistrations(documents);
admin.__seedEvent('synthetic-sort-event', {
location: 'Synthetic Sort Park',
slug: 'synthetic-sort-event',
startAt: { _seconds: 1_900_000_000 },
title: 'Synthetic Sort Event',
});
const logSpies = ['debug', 'error', 'info', 'log', 'warn'].map((method) => (
jest.spyOn(console, method).mockImplementation(() => {})
));

let caught;
let logCalls;
let resolved;
try {
try {
resolved = await listMyRegistrations({}, CONTEXT);
} catch (error) {
caught = error;
}
} finally {
logCalls = logSpies.map((spy) => spy.mock.calls.length);
logSpies.forEach((spy) => spy.mockRestore());
}
const rawFailureEscaped = caught === hostileFailure;
const publicFailure = caught && !rawFailureEscaped ? {
causeAbsent: caught.cause === undefined,
code: caught.code,
detailsAbsent: caught.details === undefined,
message: caught.message,
} : null;

expect({
coercionReads,
eventCollectionCalls: admin.__eventCollectionCalls(),
eventLookupStages: admin.__eventLookupStages(),
eventReads: admin.__eventReads(),
eventSnapshotStages: admin.__eventSnapshotStages(),
functionLoggerCalls: firebaseFunctions.__loggerCalls(),
hostileReads,
logCalls,
publicFailure,
queries: admin.__queries(),
queryStages: admin.__registrationQueryStages(),
rawFailureEscaped,
registrationDataCalls: documents.map(
(document) => document.data.mock.calls.length,
),
resolved,
secondsReads,
}).toEqual({
coercionReads: expectedCoercionReads,
eventCollectionCalls: 1,
eventLookupStages: [
'collection',
'doc:synthetic-sort-event',
'get:synthetic-sort-event',
],
eventReads: ['synthetic-sort-event'],
eventSnapshotStages: [
'exists:synthetic-sort-event',
'data:synthetic-sort-event',
],
functionLoggerCalls: 0,
hostileReads: 0,
logCalls: [0, 0, 0, 0, 0],
publicFailure: {
causeAbsent: true,
code: 'unavailable',
detailsAbsent: true,
message: 'Registration data could not be loaded.',
},
queries: [{
field: 'uid',
operator: '==',
value: CONTEXT.auth.uid,
}],
queryStages: ['firestore', 'collectionGroup', 'where', 'get'],
rawFailureEscaped: false,
registrationDataCalls: [2, 2],
resolved: undefined,
secondsReads: expectedSecondsReads,
});
expect(requireAppCheck).toHaveBeenCalledTimes(1);
expect(requireAppCheck).toHaveBeenCalledWith(CONTEXT);
expect(admin.firestore).toHaveBeenCalledTimes(1);
});

test('preserves descending created-time order and missing-time fallback', async () => {
const missingTime = registrationDocument({
createdSeconds: 1_800_000_030,
eventId: 'synthetic-sort-event',
id: 'synthetic-sort-registration-missing',
});
const missingTimeRecord = missingTime.data();
delete missingTimeRecord.createdAt;
missingTime.data.mockClear();
admin.__seedRegistrations([
missingTime,
registrationDocument({
createdSeconds: 1_800_000_010,
eventId: 'synthetic-sort-event',
id: 'synthetic-sort-registration-older',
}),
registrationDocument({
createdSeconds: 1_800_000_020,
eventId: 'synthetic-sort-event',
id: 'synthetic-sort-registration-newer',
}),
]);
admin.__seedEvent('synthetic-sort-event', {
location: 'Synthetic Sort Park',
slug: 'synthetic-sort-event',
startAt: { _seconds: 1_900_000_000 },
title: 'Synthetic Sort Event',
});

const result = await listMyRegistrations({}, CONTEXT);

expect(result.registrations.map(({ createdAt, id }) => ({ createdAt, id }))).toEqual([
{
createdAt: { _seconds: 1_800_000_020 },
id: 'synthetic-sort-registration-newer',
},
{
createdAt: { _seconds: 1_800_000_010 },
id: 'synthetic-sort-registration-older',
},
{
createdAt: null,
id: 'synthetic-sort-registration-missing',
},
]);
expect(admin.__eventReads()).toEqual(['synthetic-sort-event']);
expect(admin.__eventSnapshotStages()).toEqual([
'exists:synthetic-sort-event',
'data:synthetic-sort-event',
]);
expect(firebaseFunctions.__loggerCalls()).toBe(0);
});
});