diff --git a/.github/scripts/verify-firebase-release.mjs b/.github/scripts/verify-firebase-release.mjs index 3be681d..22c3e01 100644 --- a/.github/scripts/verify-firebase-release.mjs +++ b/.github/scripts/verify-firebase-release.mjs @@ -37,14 +37,24 @@ export function firestoreReleaseNames(project) { }; } -function summarizeFunction(id, value) { +export function summarizeFunction(id, value) { if (!value) return null; + // The v1 API's min_instances is an implicit-presence proto3 int32: its + // zero value can be omitted from JSON. Normalize only that omission and + // preserve its provenance; null/malformed values and other gaps still fail. + // https://protobuf.dev/programming-guides/json/#presence-and-default-values + const hasMinInstances = Object.hasOwn(value, 'minInstances'); return { id, name: value.name, status: value.status, entryPoint: value.entryPoint, runtime: value.runtime, + minInstances: hasMinInstances ? value.minInstances : 0, + minInstancesReadback: hasMinInstances ? 'explicit' : 'proto3-default-zero', + maxInstances: value.maxInstances, + availableMemoryMb: value.availableMemoryMb, + timeout: value.timeout, updateTime: value.updateTime, versionId: value.versionId, buildId: value.buildId, @@ -129,6 +139,10 @@ export function validateBackendState(before, after, expectedRulesDigest) { if (current.runtime !== RUNTIME || current.entryPoint !== spec.entryPoint) { errors.push(`${id} has the wrong runtime or entry point.`); } + if (current.minInstances !== 0 || current.maxInstances !== 2 + || current.availableMemoryMb !== 256 || current.timeout !== '30s') { + errors.push(`${id} has unverified or different profile resource limits.`); + } if (current.trigger !== spec.trigger) { errors.push(`${id} has the wrong trigger type.`); } diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index ad4bce3..54b1067 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -140,6 +140,8 @@ Do not redesign routing or hosting in this issue; WEB-001 owns that. WEB-001A1 [ ## CI-001 — Repair test gates and secure the deployment pipeline +**COST-001 source child:** [#683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) binds only the two profile Functions to zero reserved instances, max two each, 256 MB and 30 seconds, with actual SDK metadata tests and matching fail-closed provider readback validation. It records the community-first minimal-service direction and officer review procedure without changing schema, privileges, deployment scope, billing, providers, or public pages. This is source preparation, not reduced-bill or hosted quality evidence; #113/#133/#136 retain release prerequisites, synthetic cold-start/burst/recovery proof and compatible rollback. + **Labels:** `priority:P0`, `type:security`, `type:testing`, `area:ci`, `size:L`, `needs-external-config` **Status:** Published as tracker [#105](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/105). [#103](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/103), [#124](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/124), and [#126](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/126) provide the local/hosted frontend and SPA baselines. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) owns the dedicated hosted command-journal Firestore transaction gate. CI-001B4 [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) merged through [PR #189](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/189) as exact main `bec7d5e365eacb418563a172029f241f660d9768`; exact PR run `29291402007` and post-merge run `29291515653` passed. CI-001B4A [#227](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/227), CI-001B4B [#239](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/239), and PAY-004C1 [#359](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/359) are focused reviewed reductions of that ledger. [PR #391](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/391) and [PR #392](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/392) add three TypeScript files while retiring four TSX errors during functional work. CI-001B4C [#449](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/449) retires four stale unknown-rule directive records through comment-only same-line substitutions. MEMBERS-CONTENT-001B [#492](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/492) removes one finding-free dormant JSX file. The gate now records 110 files, 113 configured errors, and 6 warnings. [#135](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/135) owns the manual exact-commit, backend-first source gate and Git-triggered Netlify production containment. [#133](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/133) owns protected OIDC/environment configuration, and [#136](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/136) owns the actual profile-recovery release. Branch-required-check configuration, scanning, staging/live evidence, remaining lint-debt cleanup, and protected Netlify publication remain open, so the pipeline is still non-compliant overall. **Depends on:** SAFETY-001 diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 48dd597..1e74b8b 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -20,6 +20,7 @@ Attach the exact wording, public link, or approved photo when you have it. The A | Approve a change and check whether it is really live | [Publish and check](./docs/officers/PUBLISH_AND_CHECK.md) | | The site is wrong, down, unsafe, or showing private information | [Emergency and recovery](./docs/officers/EMERGENCY_AND_RECOVERY.md) | | Prepare backup officers and account access | [Access continuity](./docs/officers/ACCESS_CONTINUITY.md) | +| Keep the community website simple and review costs | [Low-cost community scope](./docs/officers/LOW_COST_COMMUNITY.md) | | Understand the pages and services | [Simple system maps](./docs/officers/SYSTEM_MAPS.md) | | Understand an unfamiliar word | [Plain-language glossary](./docs/officers/GLOSSARY.md) | diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 5949345..7f5aeed 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -990,6 +990,10 @@ Never use destructive Git or Firestore reset operations on production as a rollb ### Missing member profile repair — NOT AVAILABLE YET +COST-001 [#683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) adds source-only resource limits for this pair: `minInstances: 0`, `maxInstances: 2`, `memory: '256MB'`, and `timeoutSeconds: 30`. The actual SDK endpoint metadata is tested. The backend verifier reads the v1 provider fields `minInstances`, `maxInstances`, `availableMemoryMb`, and `timeout`. The API defines minimum instances as a proto3 implicit-presence integer, so an omitted field represents zero; the summary records `minInstancesReadback: 'proto3-default-zero'` instead of claiming an explicit value was returned. Null, malformed or differing values and missing maximum/memory/timeout evidence are rejected. An unreadable or incomplete Function cannot pass through this normalization. Existing project/revision/Rules/trigger checks remain required. See the [API schema](https://github.com/googleapis/googleapis/blob/master/google/cloud/functions/v1/functions.proto) and [JSON presence semantics](https://protobuf.dev/programming-guides/json/#presence-and-default-values). + +No migration is needed: profile schema, create-once transactions, claims, App Check/auth behavior, and trigger identities are unchanged. Execution timeouts and saturation may produce uncertain/busy results; the existing recovery path must preserve already committed data. Test cold starts and a small synthetic concurrent burst in staging before approving these initial limits for production. Max instances is not a hard monetary ceiling and may be briefly exceeded by the provider. Budgets/eligible spend caps, artifact retention, minimal runtime IAM, callable App Check enforcement, and the Node 20 maintenance deadline must be handled and verified in the provider release. Do not deploy extra Functions to make unrelated screens work. See the no-terminal [low-cost community review](./docs/officers/LOW_COST_COMMUNITY.md). + Issue [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) replaces manual repair with create-once signup/recovery Functions and a fail-closed account page. Do not use it as a production procedure until #105 supplies a protected staging and deployment path. Source guard #674 may be reviewed before billing exists, but it is not deployment evidence. Stop before any provider mutation unless the board/platform owner separately approves a Cloud Billing account and spending controls. A billing link changes the project to pay-as-you-go even when expected Function usage is within no-cost quotas. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 73b61c9..e3e14f9 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -10,6 +10,12 @@ The repository already contains a substantial prototype: a public React site, Fi ## 1. Goals and non-goals +### Community-first cost direction — 2026-09-11 + +The owner requests a simpler, low-cost community experience and is willing to supply a personal card as fallback. Prioritize public run/event/join/contact information and optional secure accounts; prefer static delivery and bounded data reads. The first profile release remains only `createMemberOnSignUp` plus `ensureMemberProfile`, not the prototype's commerce, mail, photo-directory, or synchronization services. Preserve their source and security requirements for separately approved later work; this is not permission to weaken trust boundaries or remove existing club services. + +COST-001 [#683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) explicitly binds both profile Functions to zero reserved instances, maximum two instances each, 256 MB memory and a 30-second execution timeout. The release verifier requires matching provider readback. It records the v1 API's documented omitted-zero encoding for minimum instances; missing nonzero settings and malformed evidence remain unverified. Source and tests do not make these settings live or guarantee a bill ceiling. Cold-start, saturation, uncertain-timeout/retry and public-page availability checks remain required before release. The [officer guide](./docs/officers/LOW_COST_COMMUNITY.md) records scope, cost-control limits, proof, and recovery. Node runtime maintenance and existing provider/release gates remain open; no schema, role, price, domain, or deployed service changes in this slice. + ### Goals - Publish public club content, events, and merchandise. diff --git a/docs/officers/LOW_COST_COMMUNITY.md b/docs/officers/LOW_COST_COMMUNITY.md new file mode 100644 index 0000000..1f9e18e --- /dev/null +++ b/docs/officers/LOW_COST_COMMUNITY.md @@ -0,0 +1,79 @@ +# A simple, low-cost community website + +**Purpose:** keep the community experience useful while limiting services, recurring work, and spending exposure. + +**Approver:** Dave Liu for the current simplification request. Any release still uses the named platform/security reviewers. Regular club expenditure needs the club's finance approval; willingness to supply a personal card is not an unlimited club budget. + +**Checked:** 2026-09-11. This is a scope and source-review guide, not proof that the new settings are live. + +## What we are prioritizing + +- Clear information about regular runs, meeting places, joining, and contacts. +- A useful events/calendar experience on phones as well as computers. +- Public information without requiring an account. +- Optional secure accounts for features that actually need them. +- Existing club-approved external links when an outside service already does the job. + +Prefer static pages and published public content: visitors can receive files from the host without running a server operation for each page view. Where events need database content, use bounded reads and avoid unnecessary polling. This is the implementation direction, not a claim that the Events service has been repaired. Production Events/Calendar still have the recorded unavailable state pending their own reviewed release. + +The first profile backend remains exactly two server operations: create a missing account profile and recover it safely. Keep custom checkout, refunds, inventory, automatic email, photo uploads, directory search, and Strava synchronization out of that deployment. Their existing code and future requirements remain preserved. Connecting any of them needs its own useful community outcome, cost review, tests, and approval. Do not remove a current external membership/payment service or change club policies through this guide. + +## Cost controls in source — NOT AVAILABLE YET in hosted Functions + +[Issue #683](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/683) sets the same resource options on both profile operations: + +| Setting | Meaning | +| --- | --- | +| Minimum instances: 0 | No server instance is reserved to sit idle. A first request after inactivity can take longer. | +| Maximum instances: 2 per operation | Limit normal automatic scaling. This is not two users or two requests per month. | +| Memory: 256 MB | Use the existing small memory size explicitly. | +| Execution timeout: 30 seconds | Stop unusually long execution; a timed-out request can have an uncertain result. | + +These are resource settings, **not a maximum monthly bill**. Google can briefly exceed the instance limit during traffic surges. A busy HTTP operation can queue requests and return a temporary busy response. Persistent storage, deployment files, database use, and other services can still cost money. The profile remains create-once so retrying an uncertain result does not overwrite existing information or grant membership. + +No budget, spending cap, artifact-cleanup policy, billing account, Function, or new host is configured by this source change. Billing was separately checked as disabled/unlinked on 2026-09-11. A personal card has been offered as fallback; card details belong only in Google's secure payment form. + +## Review before a low-cost release + +**Prerequisites:** issue #683 and its reviewed pull request; the exact release source and test results; a named release approver; a private billing/payment-owner decision; and a platform maintainer. The existing release dependencies still apply. Never put payment details, passwords, or member records in an issue. + +```mermaid +flowchart LR + Scope["Confirm useful community scope"] --> Source["Review paired limits and tests"] + Source --> Provider["Maintainer verifies actual settings and cost controls"] + Provider --> Quality["Check made-up accounts, cold start, burst and recovery"] + Quality --> Decision{"All proofs and approvals present?"} + Decision -- Yes --> Release["Use approved backend-first release"] + Decision -- No --> Hold["Keep release on hold"] +``` + +Text alternative: confirm the small scope, review source, verify provider controls, test quality with made-up accounts, and release only when every proof and approval is present. + +1. Confirm the public pages to keep and the two profile operations to deploy. +2. Confirm the release adds no payment, email, photo, or synchronization service. +3. Ask the maintainer for the exact merged source and passing tests. +4. Ask for separate provider evidence of all four resource settings on each Function. Google may leave out a zero minimum-instance value; the maintainer must show that this was decoded using Google's documented format. Other missing or malformed settings are not proof of safe defaults. +5. Ask for the agreed alert recipients and budget amount. Alerts do not stop charges. +6. Ask whether a service spending cap is available and configured. It does not guarantee a whole-project bill ceiling or instant shutdown. +7. Ask for deployment-file cleanup and storage-retention evidence. Do not delete database backups as a cost shortcut. +8. Observe the maintainer's made-up account checks: first sign-in after inactivity, a small concurrent burst, profile recovery, retry, and the readable busy/unavailable state. Never create or edit a real member for this test. +9. Confirm public run/join/contact information stays usable when the account service is unavailable. Events data may still depend on the database; do not promise an offline calendar unless separately tested. +10. Record the date, named reviewers, source revision, fixed pass/fail results, and separate source/merge/website/Firebase/provider states. + +**Expected result:** officers can identify the small launch scope, understand the limits, and verify the release without a terminal or payment details. + +**Stop conditions:** unknown billing owner, unapproved service, claims of guaranteed zero cost, missing provider settings, poor cold-start/burst behavior, real-data testing, an unexpected allow, or missing deployment/rollback evidence. Stop if runtime maintenance or the existing #113/#133/#136 dependencies remain unresolved for the requested release. + +**Success proof:** a reviewed source/CI record plus separate provider readback and synthetic quality evidence. Source tests alone cannot prove lower bills or a good hosted experience. + +**Undo:** before release, hold the change. After release, ask the platform maintainer to use the reviewed compatible rollback or safe roll-forward. Reverting the limits can raise spending exposure and requires review. Do not edit a profile, widen permissions, switch off billing, or delete resources as an improvised fix. + +**Escalation:** platform/security owner for reliability or access; treasurer/finance approver for recurring club costs; cardholder for payment authority. A specialist is still needed for provider setup, release, and rollback. This guide does not claim independent officer deployment is available. + +## References + +- [Firebase resource settings and saturation behavior](https://firebase.google.com/docs/functions/1st-gen/manage-functions-1st) +- [Google maximum-instance caveat](https://docs.cloud.google.com/functions/docs/reference/rest/v1/projects.locations.functions) +- [Google's zero-value response format](https://protobuf.dev/programming-guides/json/#presence-and-default-values) +- [Spending-cap coverage and overages](https://docs.cloud.google.com/billing/docs/how-to/budgets-spend-caps) +- [Firebase Hosting usage](https://firebase.google.com/docs/hosting/usage-quotas-pricing) diff --git a/docs/officers/README.md b/docs/officers/README.md index b5ea416..88754f2 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -37,6 +37,7 @@ In words: approve the merge, request one exact release, and approve its protecte 6. [Access continuity](./ACCESS_CONTINUITY.md) 7. [Simple system maps](./SYSTEM_MAPS.md) 8. [Plain-language glossary](./GLOSSARY.md) +9. [Low-cost community scope and release review](./LOW_COST_COMMUNITY.md) ## Five rules diff --git a/functions/ensureMemberProfile.js b/functions/ensureMemberProfile.js index 77d8d65..02af4f7 100644 --- a/functions/ensureMemberProfile.js +++ b/functions/ensureMemberProfile.js @@ -1,5 +1,6 @@ const functions = require('firebase-functions'); const admin = require('firebase-admin'); +const profileRuntime = require('./profileRuntime'); const { requireAppCheck } = require('./stripeHelpers'); const { ensureMemberProfileDocument, @@ -13,7 +14,7 @@ function isEmptyRequest(data) { && Object.keys(data).length === 0; } -exports.ensureMemberProfile = functions.https.onCall(async (data, context) => { +exports.ensureMemberProfile = functions.runWith(profileRuntime).https.onCall(async (data, context) => { requireAppCheck(context); if (!context.auth) { diff --git a/functions/ensureMemberProfile.test.js b/functions/ensureMemberProfile.test.js index ba6359e..17ad86a 100644 --- a/functions/ensureMemberProfile.test.js +++ b/functions/ensureMemberProfile.test.js @@ -68,13 +68,14 @@ jest.mock('firebase-functions', () => { } } - return { + const builder = { auth: { user: () => ({ onCreate: (handler) => handler }) }, https: { onCall: (handler) => handler, HttpsError, }, }; + return { ...builder, runWith: () => builder }; }); jest.mock('./stripeHelpers', () => ({ diff --git a/functions/profileRuntime.js b/functions/profileRuntime.js new file mode 100644 index 0000000..fb146d8 --- /dev/null +++ b/functions/profileRuntime.js @@ -0,0 +1,8 @@ +// Resource limits for the two small account-profile operations only. +// These reduce idle/scaling exposure; they are not a monthly spending cap. +module.exports = Object.freeze({ + minInstances: 0, + maxInstances: 2, + memory: '256MB', + timeoutSeconds: 30, +}); diff --git a/functions/profileRuntime.test.js b/functions/profileRuntime.test.js new file mode 100644 index 0000000..9c64823 --- /dev/null +++ b/functions/profileRuntime.test.js @@ -0,0 +1,40 @@ +// Use the installed SDK, not a fluent-builder mock, to inspect what deployment +// will receive. Importing these modules never invokes Auth, Firestore or a user. +const { onSignUp } = require('./signup'); +const { ensureMemberProfile } = require('./ensureMemberProfile'); + +describe('minimal profile backend resource limits', () => { + const originalProject = process.env.GCLOUD_PROJECT; + + beforeAll(() => { + // Auth endpoint metadata needs a project label even though no request is + // sent. Keep this fixture self-contained in credential-free hosted CI. + process.env.GCLOUD_PROJECT = 'demo-mprc-local'; + }); + + afterAll(() => { + if (originalProject === undefined) delete process.env.GCLOUD_PROJECT; + else process.env.GCLOUD_PROJECT = originalProject; + }); + + test.each([ + ['createMemberOnSignUp', onSignUp], + ['ensureMemberProfile', ensureMemberProfile], + ])('%s emits the exact low-idle, bounded resource settings', (name, handler) => { + expect(handler.__endpoint).toMatchObject({ + platform: 'gcfv1', + minInstances: 0, + maxInstances: 2, + availableMemoryMb: 256, + timeoutSeconds: 30, + }); + }); + + test('keeps the existing Auth-created trigger and callable identities', () => { + expect(onSignUp.__endpoint.eventTrigger.eventType) + .toBe('providers/firebase.auth/eventTypes/user.create'); + expect(ensureMemberProfile.__endpoint.callableTrigger).toBeDefined(); + expect(onSignUp.__endpoint.callableTrigger).toBeUndefined(); + expect(ensureMemberProfile.__endpoint.eventTrigger).toBeUndefined(); + }); +}); diff --git a/functions/signup.js b/functions/signup.js index 468630d..7750baf 100644 --- a/functions/signup.js +++ b/functions/signup.js @@ -1,4 +1,5 @@ const functions = require('firebase-functions'); +const profileRuntime = require('./profileRuntime'); const { ensureMemberProfileDocument } = require('./memberProfile'); /** @@ -8,7 +9,7 @@ async function createMemberDocument(user) { return ensureMemberProfileDocument(user); } -exports.onSignUp = functions.auth.user().onCreate(async (user) => { +exports.onSignUp = functions.runWith(profileRuntime).auth.user().onCreate(async (user) => { try { // Missing claims already fail closed. Only the reviewed role-grant path may // change them; a delayed signup trigger must never replace a claim map. diff --git a/tests/firebase-release-verification.test.js b/tests/firebase-release-verification.test.js index 95c61b4..359fa59 100644 --- a/tests/firebase-release-verification.test.js +++ b/tests/firebase-release-verification.test.js @@ -18,6 +18,10 @@ function functionState(project, id, overrides = {}) { status: 'ACTIVE', entryPoint: id, runtime: 'nodejs20', + minInstances: 0, + maxInstances: 2, + availableMemoryMb: 256, + timeout: '30s', updateTime: '2026-07-13T09:00:00Z', versionId: '2', buildId: 'build-2', @@ -38,6 +42,58 @@ function rulesState(project, rulesetId, files, overrides = {}) { }; } +test('retains only safe resource settings and records the documented zero-value encoding', async () => { + const { summarizeFunction } = await import(SCRIPT_URL); + const id = 'ensureMemberProfile'; + const raw = { + ...functionState('demo-approved-project', id), + httpsTrigger: {}, + environmentVariables: { SYNTHETIC_SECRET: 'not-a-real-secret' }, + }; + const summary = summarizeFunction(id, raw); + assert.equal(summary.minInstances, 0); + assert.equal(summary.minInstancesReadback, 'explicit'); + assert.equal(summary.maxInstances, 2); + assert.equal(summary.availableMemoryMb, 256); + assert.equal(summary.timeout, '30s'); + assert.equal(summary.environmentVariables, undefined); + assert.equal(summarizeFunction(id, null), null); + const omittedZero = { ...raw }; + delete omittedZero.minInstances; + assert.equal(summarizeFunction(id, omittedZero).minInstances, 0); + assert.equal(summarizeFunction(id, omittedZero).minInstancesReadback, 'proto3-default-zero'); + assert.equal(summarizeFunction(id, { ...raw, minInstances: null }).minInstances, null); + assert.equal(summarizeFunction(id, { ...raw, minInstances: '0' }).minInstances, '0'); + for (const field of ['maxInstances', 'availableMemoryMb', 'timeout']) { + const incomplete = { ...raw }; + delete incomplete[field]; + assert.equal(summarizeFunction(id, incomplete)[field], undefined); + } +}); + +test('accepts an omitted proto3 minimum only with the remaining complete provider evidence', async () => { + const { summarizeFunction, validateBackendState } = await import(SCRIPT_URL); + const project = 'demo-approved-project'; + const digest = 'expected'; + const functions = {}; + for (const id of ['createMemberOnSignUp', 'ensureMemberProfile']) { + const raw = functionState(project, id); + delete raw.minInstances; + if (id === 'createMemberOnSignUp') raw.eventTrigger = { eventType: raw.eventType }; + else raw.httpsTrigger = {}; + functions[id] = summarizeFunction(id, raw); + } + const after = { + project, + rules: rulesState(project, 'new', [{ name: 'firestore.rules', digest }]), + functions, + }; + assert.deepEqual(validateBackendState({ project }, after, digest), []); + functions.ensureMemberProfile = summarizeFunction('ensureMemberProfile', {}); + assert.ok(validateBackendState({ project }, after, digest) + .includes('ensureMemberProfile has unverified or different profile resource limits.')); +}); + test('requests the default database path but expects its canonical release name', async () => { const { firestoreReleaseNames } = await import(SCRIPT_URL); assert.deepEqual(firestoreReleaseNames('demo-approved-project'), { @@ -177,6 +233,37 @@ test('rejects missing Function revision evidence', async () => { assert.ok(errors.includes('createMemberOnSignUp revision metadata is unreadable.')); }); +test('rejects missing, malformed or different resource limits on either Function', async () => { + const { validateBackendState } = await import(SCRIPT_URL); + const project = 'demo-approved-project'; + const digest = 'expected'; + const invalidLimits = [ + { minInstances: 1 }, { minInstances: undefined }, { minInstances: null }, + { minInstances: '0' }, { maxInstances: undefined }, { maxInstances: 3 }, + { maxInstances: '2' }, { availableMemoryMb: undefined }, + { availableMemoryMb: 512 }, { availableMemoryMb: '256' }, + { timeout: undefined }, { timeout: '540s' }, { timeout: 30 }, + ]; + + for (const id of ['createMemberOnSignUp', 'ensureMemberProfile']) { + for (const limits of invalidLimits) { + const functions = { + createMemberOnSignUp: functionState(project, 'createMemberOnSignUp'), + ensureMemberProfile: functionState(project, 'ensureMemberProfile'), + }; + functions[id] = functionState(project, id, limits); + const after = { + project, + rules: rulesState(project, 'new', [{ name: 'firestore.rules', digest }]), + functions, + }; + assert.deepEqual(validateBackendState({ project }, after, digest), [ + `${id} has unverified or different profile resource limits.`, + ]); + } + } +}); + test('fails closed when provider readback is incomplete or malformed', async () => { const { validateBackendState } = await import(SCRIPT_URL); const errors = validateBackendState(