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
8 changes: 4 additions & 4 deletions SYSTEM_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ The first release may continue using `admin` in the UI, but server endpoints and
| `events/{eventId}/registrations/{registrationId}` | Participant identity, waiver evidence, payment references, lifecycle | Cloud Functions and admins today; target Cloud Functions only | Restricted PII and financial metadata |
| `products/{productId}` | Merchandise catalog | Admin client today | Public plus internal configuration |
| `orders/{orderId}` | Buyer, shipping, payment, and fulfillment data | Cloud Functions and admins today; target Cloud Functions only | Restricted PII and financial metadata |
| `members/{uid}` | Profile and role mirror | Create-once signup/recovery Functions; self-service name-only allowlist while #178 pauses phone collection; server role operations | Confidential |
| `members/{uid}` | Profile and role mirror | Create-once signup/recovery Functions create phone-free pending profiles; self-service name-only allowlist while #178/#197 pause phone collection; server role operations | Confidential |
| `members/{uid}/connections/{provider}` | Non-secret connection metadata | Cloud Functions | Confidential |
| `members/{uid}/secrets/{provider}` | OAuth tokens | Cloud Functions | Restricted secret |
| `promoCodes/{id}` | Intended promotion configuration | Admin only | Confidential; currently not integrated into checkout validation |
Expand Down Expand Up @@ -352,7 +352,7 @@ sequenceDiagram
F-->>W: ready=true; no write or claim change
else profile missing
F->>A: Load caller's authoritative Auth record
F->>D: Transactionally recheck and create pending profile once
F->>D: Transactionally create one phone-free pending profile
F-->>W: ready=true
end
W->>D: Read caller profile through Firestore Rules
Expand All @@ -366,9 +366,9 @@ sequenceDiagram
end
```

The callable accepts no UID or profile fields. A missing record receives identity fields from Firebase Auth and `role: unverified`; it never copies a member/admin claim, dues, payment, or discount state. Existing records and claims remain unchanged. This may expose a pre-existing claim/profile mismatch instead of guessing how to repair it; the identity/membership workflow must resolve that mismatch explicitly. App Check is required by the shared boundary only when deployed enforcement is configured, so release evidence must prove that setting before calling App Check live.
The callable accepts no UID or profile fields. A missing record receives bounded name, email, verification, and provider fields from Firebase Auth plus `role: unverified`; while phone collection is paused, the server writes the existing schema-compatible empty phone field instead of copying `user.phoneNumber`. It never copies a member/admin claim, dues, payment, or discount state. Existing records and claims remain byte-for-byte unchanged. This may expose a pre-existing claim/profile mismatch instead of guessing how to repair it; the identity/membership workflow must resolve that mismatch explicitly. App Check is required by the shared boundary only when deployed enforcement is configured, so release evidence must prove that setting before calling App Check live.

DATA-001C1 [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178) pauses optional phone display and collection in My Account. The owner-profile projection omits `phoneNumber`, the client validates and writes only `fullName`, and the Rules source rejects every browser phone mutation while allowing an existing phone value to remain unchanged during a name edit. Firestore still authorizes and transports the owner's complete document at its document-level boundary; #116 retains the future server-projection work for broader administrative reads. There is no migration, deletion, export, Function/Auth change, Google Forms change, or provider action in this slice. The source is not live protection until the exact Rules are deployed and read back before the dependent website revision is published and verified.
DATA-001C1 [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178) pauses optional phone display and collection in My Account. The owner-profile projection omits `phoneNumber`, the client validates and writes only `fullName`, and the Rules source rejects every browser phone mutation while allowing an existing phone value to remain unchanged during a name edit. DATA-001C2 [#197](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/197) extends the same pause to the shared signup/recovery helper: a new profile keeps the empty phone schema field even when Firebase Auth already has a phone. The create-once transaction leaves every existing profile unchanged. Firestore still authorizes and transports the owner's complete document at its document-level boundary; #116 retains future server projections for broader administrative reads. Neither slice deletes, migrates, exports, or inspects existing phone data, changes Google Forms/providers, or proves spam causation. Source is not live protection until the exact Rules and both profile Functions are deployed/read back before the dependent website revision is published and verified.

The server chooses initial timestamps. The current self-edit path sends a Firestore server timestamp, but the Rules source type-checks rather than independently proves that edit timestamp. Do not describe arbitrary profile edit timestamps as server-authoritative until a coordinated Rules/API issue closes that residual.

Expand Down
23 changes: 13 additions & 10 deletions docs/officers/EVENTS_SHOP_MEMBERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,38 +203,41 @@ Safe officer steps:

**Approver:** membership lead plus privacy/platform owner.

**Prerequisites:** claimed issue [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178), a private redacted incident record under #112, the authorized service inventory under #113, made-up test data, and the protected backend-first release path. Do not put a member's number, spam message, screenshot, or provider record in GitHub, email, or AI.
**Prerequisites:** reviewed source issues [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178) and [#197](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/197), a private redacted incident record under #112, the authorized service inventory under #113, made-up test data, and the protected backend-first release path. Do not put a member's number, spam message, screenshot, or provider record in GitHub, email, or AI.

```mermaid
flowchart LR
Account["Member opens My Account"] --> Read["Read this member's profile"]
Account["Member opens My Account"] --> Setup{"Profile exists?"}
Setup -- "Yes" --> Read["Read this member's profile"]
Setup -- "No" --> New["Create one pending profile\nwith an empty phone field"]
New --> Read
Read --> Name["Display and edit name"]
Read --> Pause["Do not display or accept phone"]
Name --> Rules["Firebase Rules allow name-only update"]
PhoneAttempt["Browser tries a phone change"] --> Deny["Firebase Rules deny"]
Pause --> Existing["Existing stored value stays unchanged"]
```

In words: My Account shows and edits the member's name, does not display or accept a phone number, and leaves any existing stored value unchanged; the reviewed Rules deny a browser phone change.
In words: signup or profile recovery creates a missing pending profile without copying a phone from Firebase Auth; My Account shows and edits the member's name, does not display or accept a phone number, and leaves every existing profile unchanged; the reviewed Rules deny a browser phone change.

Officer steps after every prerequisite has proof:

1. Tell members not to add a phone number in My Account.
2. Do not ask whether a member already has a number stored.
3. Do not copy a number or spam message into an issue, email, screenshot, or AI tool.
4. Keep the Google membership form, event registration, shop, and provider review separate; this source change does not alter them.
5. Ask the platform owner to identify the exact merged website and Rules revisions.
6. Require the reviewed Rules to deploy and pass readback before the website is published.
7. Use one made-up staged profile to prove name-only editing and phone-write denial.
5. Ask the platform owner to identify the exact merged website, Rules, and profile-Function revisions.
6. Require the reviewed Rules and both profile Functions to deploy and pass readback before the website is published.
7. Ask the platform owner to use one made-up staged Auth account with a synthetic phone and no profile to prove the new pending profile stores no copied phone; then prove name-only editing and browser phone-write denial.
8. Check the exact website revision on `runmprc.com` without opening a real member profile.

**Expected result:** My Account contains no phone value, phone input, or phone browser autocomplete. A name-only change succeeds. A direct non-empty phone change is denied. Existing stored data, membership, payment status, and external forms/providers remain unchanged.
**Expected result:** a newly created pending profile has the empty phone field even if the made-up Auth account has a phone. My Account contains no phone value, phone input, or phone browser autocomplete. A name-only change succeeds. A direct non-empty phone change is denied. Existing profiles, membership, payment status, and external forms/providers remain unchanged.

**Stop conditions:** a real member profile, a provider Console change, a database export, a request to inspect or delete stored numbers, skipped/partial Rules deployment, website publication before Rules proof, or a proposal to treat spam timing as proof of a breach.
**Stop conditions:** a real member profile, a provider Console change, a database export, a request to inspect or delete stored numbers, skipped/partial Rules or profile-Function deployment, website publication before backend proof, or a proposal to treat spam timing as proof of a breach.

**Success proof:** exact #178 pull request and merge commit; green synthetic frontend and Rules tests; exact Rules deployment/readback; later website publication record; separate `runmprc.com` revision check; and a dated made-up name-only/phone-denial check. Google, Firebase, Sentry, Stripe, and other provider evidence remains separate and private.
**Success proof:** exact #178 and #197 pull requests and merge commits; green synthetic frontend, Functions, emulator, and Rules tests; exact Rules and profile-Function deployment/readback; later website publication record; separate `runmprc.com` revision check; and a dated made-up phone-free bootstrap/name-only/phone-denial check. Google, Sentry, Stripe, and other provider evidence remains separate and private.

**Undo:** use one reviewed revert or safe roll-forward through the same backend-first gate. Do not restore phone collection until #110 approves its purpose, notice, access, and retention, and #113/#133/#136 prove the intended live boundary.
**Undo:** use one reviewed revert or safe roll-forward through the same backend-first gate. Do not restore browser collection or Auth-phone copying until #110 approves its purpose, notice, access, and retention, and #113/#133/#136 prove the intended live boundary.

**Escalation:** membership lead plus privacy/platform owner; use the private incident path under #112 if exposure is suspected.

Expand Down
47 changes: 37 additions & 10 deletions functions/ensureMemberProfile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,14 @@ jest.mock('./stripeHelpers', () => ({
const admin = require('firebase-admin');
const { requireAppCheck } = require('./stripeHelpers');

const AUTH_PHONE_CANARY = '+12025550197';

const AUTH_USER = {
uid: 'caller-uid',
email: 'member@example.com',
emailVerified: true,
displayName: 'Synthetic Member',
phoneNumber: '+16505550123',
phoneNumber: AUTH_PHONE_CANARY,
providerData: [{ providerId: 'password' }],
};

Expand All @@ -105,7 +107,7 @@ describe('ensureMemberProfile callable', () => {
admin.__mocks.getUser.mockResolvedValue(AUTH_USER);
});

test('creates one exact pending profile from the authenticated Firebase user', async () => {
test('creates one exact phone-free pending profile from the authenticated Firebase user', async () => {
const { ensureMemberProfile } = require('./ensureMemberProfile');

await expect(ensureMemberProfile({}, CONTEXT)).resolves.toEqual({ ready: true });
Expand All @@ -117,14 +119,35 @@ describe('ensureMemberProfile callable', () => {
email: 'member@example.com',
createdAt: admin.__timestamp,
lastLogin: admin.__timestamp,
phoneNumber: '+16505550123',
phoneNumber: '',
role: 'unverified',
emailVerified: true,
provider: 'password',
});
expect(JSON.stringify(admin.__read(AUTH_USER.uid)))
.not.toContain(AUTH_PHONE_CANARY);
expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled();
});

test('does not read the Firebase Auth phone while building a profile', async () => {
let phoneReads = 0;
const authUser = { ...AUTH_USER };
Object.defineProperty(authUser, 'phoneNumber', {
enumerable: true,
get: () => {
phoneReads += 1;
return AUTH_PHONE_CANARY;
},
});
admin.__mocks.getUser.mockResolvedValue(authUser);
const { ensureMemberProfile } = require('./ensureMemberProfile');

await expect(ensureMemberProfile({}, CONTEXT)).resolves.toEqual({ ready: true });

expect(phoneReads).toBe(0);
expect(admin.__read(AUTH_USER.uid)).toMatchObject({ phoneNumber: '' });
});

test.each([undefined, null, {}])('accepts an empty request shape: %p', async (data) => {
const { ensureMemberProfile } = require('./ensureMemberProfile');
await expect(ensureMemberProfile(data, CONTEXT)).resolves.toEqual({ ready: true });
Expand Down Expand Up @@ -168,12 +191,12 @@ describe('ensureMemberProfile callable', () => {
expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled();
});

test('bounds Auth-derived editable fields without truncating identity data', async () => {
test('bounds the Auth display name without retaining an Auth phone', async () => {
admin.__mocks.getUser.mockResolvedValue({
...AUTH_USER,
email: ' Member@Example.COM ',
displayName: '🏃'.repeat(101),
phoneNumber: '📞'.repeat(21),
phoneNumber: AUTH_PHONE_CANARY,
});
const { ensureMemberProfile } = require('./ensureMemberProfile');

Expand All @@ -188,21 +211,20 @@ describe('ensureMemberProfile callable', () => {
expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled();
});

test('preserves Auth-derived fields at the exact Unicode boundaries', async () => {
test('preserves the Auth display name at its exact Unicode boundary', async () => {
const displayName = '🏃'.repeat(100);
const phoneNumber = '📞'.repeat(20);
admin.__mocks.getUser.mockResolvedValue({
...AUTH_USER,
displayName,
phoneNumber,
phoneNumber: AUTH_PHONE_CANARY,
});
const { ensureMemberProfile } = require('./ensureMemberProfile');

await ensureMemberProfile({}, CONTEXT);

expect(admin.__read(AUTH_USER.uid)).toMatchObject({
fullName: displayName,
phoneNumber,
phoneNumber: '',
role: 'unverified',
});
});
Expand Down Expand Up @@ -293,7 +315,12 @@ describe('signup profile compatibility', () => {

await onSignUp(AUTH_USER);

expect(admin.__read(AUTH_USER.uid)).toMatchObject({ role: 'unverified' });
expect(admin.__read(AUTH_USER.uid)).toMatchObject({
phoneNumber: '',
role: 'unverified',
});
expect(JSON.stringify(admin.__read(AUTH_USER.uid)))
.not.toContain(AUTH_PHONE_CANARY);
expect(admin.__mocks.setCustomUserClaims).not.toHaveBeenCalled();
});

Expand Down
5 changes: 4 additions & 1 deletion functions/memberProfile.emulator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describeWithEmulator('member profile Firestore transaction', () => {
email: 'synthetic@example.test',
emailVerified: true,
displayName: 'Synthetic Runner',
phoneNumber: '+16505550123',
phoneNumber: '+12025550197',
providerData: [{ providerId: 'password' }],
};

Expand All @@ -41,6 +41,9 @@ describeWithEmulator('member profile Firestore transaction', () => {
expect(results.filter((result) => result.created)).toHaveLength(1);

const ref = db.collection('members').doc(uid);
expect((await ref.get()).data()).toMatchObject({ phoneNumber: '' });
expect(JSON.stringify((await ref.get()).data()))
.not.toContain('+12025550197');
await ref.update({
fullName: 'Member-edited name',
phoneNumber: 'Member-edited phone',
Expand Down
10 changes: 3 additions & 7 deletions functions/memberProfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ const { Timestamp } = require('firebase-admin/firestore');

const DEFAULT_ROLE = 'unverified';
const FULL_NAME_MAX_CHARACTERS = 200;
const PHONE_NUMBER_MAX_CHARACTERS = 40;

function boundedIdentityString(value, maxCharacters, fallback) {
if (typeof value !== 'string') return fallback;
Expand All @@ -22,11 +21,9 @@ function buildPendingMemberProfile(user, now = Timestamp.now()) {
email: typeof user.email === 'string' ? user.email.trim().toLowerCase() : '',
createdAt: now,
lastLogin: now,
phoneNumber: boundedIdentityString(
user.phoneNumber,
PHONE_NUMBER_MAX_CHARACTERS,
'',
),
// Keep the existing schema shape without copying optional phone data from
// Firebase Auth while profile phone collection is paused under #197.
phoneNumber: '',
role: DEFAULT_ROLE,
emailVerified: user.emailVerified === true,
provider: user.providerData?.[0]?.providerId || 'unknown',
Expand Down Expand Up @@ -66,7 +63,6 @@ async function ensureMemberProfileDocument(user) {
module.exports = {
DEFAULT_ROLE,
FULL_NAME_MAX_CHARACTERS,
PHONE_NUMBER_MAX_CHARACTERS,
buildPendingMemberProfile,
memberProfileExists,
ensureMemberProfileDocument,
Expand Down