diff --git a/CHANGELOG.md b/CHANGELOG.md index 346a248..b2b9df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ full engineering record. ## [Unreleased] +## [0.3.34] — 2026-07-31 + +- Made Request Access become a disabled Request Sent action immediately after + the first click, before the private authority response returns. +- Kept a request already recorded in SpacetimeDB visibly received and + non-clickable across later status checks. +- Added rapid-repeat coverage proving one player gesture produces at most one + bridge submission while the server remains cycle-idempotent. + ## [0.3.33] — 2026-07-31 - Gave every admitted keeper one experimental Community Mark per eligible @@ -371,7 +380,8 @@ full engineering record. Lowlands, a first keep, Farcaster sign-in, and an admission-gated shared-world foundation. -[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.33...HEAD +[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.34...HEAD +[0.3.34]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.33...v0.3.34 [0.3.33]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.32...v0.3.33 [0.3.32]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.31...v0.3.32 [0.3.31]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.30...v0.3.31 diff --git a/docs/farcaster-integration.md b/docs/farcaster-integration.md index bb6af59..60211b2 100644 --- a/docs/farcaster-integration.md +++ b/docs/farcaster-integration.md @@ -5,7 +5,7 @@ ordinary browsers use Sign In with Farcaster (SIWF), while a verified Farcaster Mini App host may use Quick Auth. Neither path is a wallet connection, client-owned identity, admission grant, or Terms acceptance. -Alpha 0.3.33 keeps backend protocol 3 and authentication contract v2; admission +Alpha 0.3.34 keeps backend protocol 3 and authentication contract v2; admission remains gated. Production configuration and founder identities belong in the private operator record, not this guide. diff --git a/docs/releases/versioning.md b/docs/releases/versioning.md index 9c9e75c..3ec6a51 100644 --- a/docs/releases/versioning.md +++ b/docs/releases/versioning.md @@ -1,7 +1,7 @@ # Versioning and releases Warpkeep uses semantic versions for the product and Git commit SHAs for builds. -Alpha `0.3.33` is the live **The Realm Remembers** release. A version is +Alpha `0.3.34` is the live **The Petition Stands** release. A version is described as live only after its protected review, matching client deployment, and production smoke test all succeed. diff --git a/package-lock.json b/package-lock.json index 5cb5a3b..4f0c284 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "warpkeep", - "version": "0.3.33", + "version": "0.3.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "warpkeep", - "version": "0.3.33", + "version": "0.3.34", "license": "Apache-2.0", "dependencies": { "@farcaster/auth-client": "0.7.1", diff --git a/package.json b/package.json index 2424233..6deef10 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "warpkeep", "private": true, - "version": "0.3.33", + "version": "0.3.34", "description": "A Farcaster-connected persistent strategy world in active Alpha development.", "license": "Apache-2.0", "homepage": "https://warpkeep.com", diff --git a/src/components/auth/FarcasterAccessRequest.tsx b/src/components/auth/FarcasterAccessRequest.tsx index 668a308..fdc1d9f 100644 --- a/src/components/auth/FarcasterAccessRequest.tsx +++ b/src/components/auth/FarcasterAccessRequest.tsx @@ -56,7 +56,7 @@ export function FarcasterAccessRequestMessage({ if (state.phase === 'submitting') { return (

- Sending request… + Request sent. Confirming with the Hegemony records…

); } @@ -102,7 +102,7 @@ export function FarcasterAccessRequestAction({ const label = isLoading ? 'CHECKING…' : isSubmitting - ? 'SENDING…' + ? 'REQUEST SENT' : isRequested ? 'REQUEST RECEIVED' : 'REQUEST ACCESS'; diff --git a/src/components/menu/latestPatchNotes.ts b/src/components/menu/latestPatchNotes.ts index 4b3e181..a590053 100644 --- a/src/components/menu/latestPatchNotes.ts +++ b/src/components/menu/latestPatchNotes.ts @@ -6,6 +6,20 @@ export type LatestPatchNotes = Readonly<{ alphaNotice: string; }>; +const ALPHA_0_3_34_PATCH_NOTES: LatestPatchNotes = Object.freeze({ + releasedOn: '31 JUL 2026', + title: 'THE PETITION STANDS', + summary: + 'A keeper’s petition now settles visibly the instant it is sent, while the Realm’s private record remains final.', + highlights: Object.freeze([ + 'Request Access becomes a disabled Request Sent action on the first click, without waiting for a network round trip.', + 'A petition already recorded by SpacetimeDB remains visibly received and cannot be submitted again from the gateway.', + 'Rapid repeated gestures are absorbed locally, while the existing private server record remains cycle-idempotent and manually reviewed.' + ]), + alphaNotice: + 'Alpha 0.3.34 remains an invite-only, unfinished world. Sending a petition does not grant admission, create a keep, or change any Realm ownership or balance.' +}); + const ALPHA_0_3_33_PATCH_NOTES: LatestPatchNotes = Object.freeze({ releasedOn: '31 JUL 2026', title: 'THE REALM REMEMBERS', @@ -233,6 +247,7 @@ const ALPHA_0_3_22_PATCH_NOTES: LatestPatchNotes = Object.freeze({ export const WARPKEEP_PATCH_NOTES_BY_VERSION: Readonly> = Object.freeze({ + '0.3.34': ALPHA_0_3_34_PATCH_NOTES, '0.3.33': ALPHA_0_3_33_PATCH_NOTES, '0.3.32': ALPHA_0_3_32_PATCH_NOTES, '0.3.31': ALPHA_0_3_31_PATCH_NOTES, diff --git a/tests/buildInfo.test.ts b/tests/buildInfo.test.ts index 7c963d1..0d75c59 100644 --- a/tests/buildInfo.test.ts +++ b/tests/buildInfo.test.ts @@ -24,7 +24,7 @@ describe('Warpkeep build identity', () => { ); }); - it('reads the 0.3.33 product version from package metadata', () => { + it('reads the 0.3.34 product version from package metadata', () => { const packageJson = JSON.parse( readFileSync(resolve(process.cwd(), 'package.json'), 'utf8') ) as { version?: unknown }; @@ -32,7 +32,7 @@ describe('Warpkeep build identity', () => { readFileSync(resolve(process.cwd(), 'package-lock.json'), 'utf8') ) as { version?: unknown; packages?: Record }; - expect(readWarpkeepProductVersion(packageJson.version)).toBe('0.3.33'); + expect(readWarpkeepProductVersion(packageJson.version)).toBe('0.3.34'); expect(packageLock.version).toBe(packageJson.version); expect(packageLock.packages?.['']?.version).toBe(packageJson.version); expect(readWarpkeepProductVersion('1.0.0-alpha.1+build.7')).toBe('1.0.0-alpha.1+build.7'); diff --git a/tests/farcasterAccessRequestLifecycle.test.tsx b/tests/farcasterAccessRequestLifecycle.test.tsx index 44e7921..4a7ff7f 100644 --- a/tests/farcasterAccessRequestLifecycle.test.tsx +++ b/tests/farcasterAccessRequestLifecycle.test.tsx @@ -8,6 +8,7 @@ import { } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FarcasterAccessRequestAction } from '../src/components/auth/FarcasterAccessRequest'; import { useAccessRequest } from '../src/farcaster/useAccessRequest'; import type { AccessRequestStatus, @@ -27,6 +28,14 @@ const pending = (fid: number): FarcasterAuthViewState => Object.freeze({ const anonymous: FarcasterAuthViewState = Object.freeze({ phase: 'anonymous' }); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return Object.freeze({ promise, resolve }); +} + function bridge(overrides: Partial = {}): FarcasterOidcBridgeClient { return { issuer: 'https://auth.warpkeep.example', @@ -76,7 +85,10 @@ function Harness({
{access.state.phase} {'requestedAt' in access.state ? : null} - +
); } @@ -173,7 +185,7 @@ describe('access-request controller lifecycle', () => { await waitFor(() => expect(screen.getByText('not-requested')).not.toBeNull()); expect(requestAccess).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: 'request' })); + fireEvent.click(screen.getByRole('button', { name: 'REQUEST ACCESS' })); await waitFor(() => expect(screen.getByText('requested')).not.toBeNull()); expect(requestAccess).toHaveBeenCalledTimes(1); expect(screen.getByText('1785414896000')).not.toBeNull(); @@ -194,7 +206,7 @@ describe('access-request controller lifecycle', () => { await waitFor(() => expect(screen.getByText('error')).not.toBeNull()); expect(requestAccess).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: 'request' })); + fireEvent.click(screen.getByRole('button', { name: 'REQUEST ACCESS' })); await waitFor(() => expect(screen.getByText('requested')).not.toBeNull()); expect(getAccessRequestStatus).toHaveBeenCalledTimes(2); @@ -212,7 +224,7 @@ describe('access-request controller lifecycle', () => { render(); await waitFor(() => expect(screen.getByText('error')).not.toBeNull()); - fireEvent.click(screen.getByRole('button', { name: 'request' })); + fireEvent.click(screen.getByRole('button', { name: 'REQUEST ACCESS' })); await waitFor(() => expect(screen.getByText('error')).not.toBeNull()); expect(getAccessRequestStatus).toHaveBeenCalledTimes(2); @@ -232,7 +244,7 @@ describe('access-request controller lifecycle', () => { render(); await waitFor(() => expect(screen.getByText('error')).not.toBeNull()); - fireEvent.click(screen.getByRole('button', { name: 'request' })); + fireEvent.click(screen.getByRole('button', { name: 'REQUEST ACCESS' })); await waitFor(() => expect(screen.getByText('requested')).not.toBeNull()); expect(getAccessRequestStatus).toHaveBeenCalledTimes(2); @@ -263,7 +275,7 @@ describe('access-request controller lifecycle', () => { ); await waitFor(() => expect(screen.getByText('not-requested')).not.toBeNull()); - fireEvent.click(screen.getByRole('button', { name: 'request' })); + fireEvent.click(screen.getByRole('button', { name: 'REQUEST ACCESS' })); await waitFor(() => expect(screen.getByText('requested')).not.toBeNull()); expect(requestAccess).toHaveBeenCalledTimes(1); @@ -276,4 +288,50 @@ describe('access-request controller lifecycle', () => { expect(document.documentElement.innerHTML).not.toContain(token); expect(JSON.stringify(screen.getByText('requested').textContent)).not.toContain(token); }); + + it('switches to a disabled sent state immediately and submits only once', async () => { + const submitted = deferred(); + const requestAccess = vi.fn(() => submitted.promise); + const client = bridge({ requestAccess }); + render(); + + await waitFor(() => expect(screen.getByText('not-requested')).not.toBeNull()); + const request = screen.getByRole('button', { name: 'REQUEST ACCESS' }); + fireEvent.click(request); + + expect(screen.getByText('submitting')).not.toBeNull(); + const sent = screen.getByRole('button', { name: 'REQUEST SENT' }) as HTMLButtonElement; + expect(sent.disabled).toBe(true); + fireEvent.click(sent); + await waitFor(() => expect(requestAccess).toHaveBeenCalledTimes(1)); + + submitted.resolve({ + version: 1, + status: 'requested', + requestedAt: 1_785_414_896_000 + }); + await waitFor(() => expect(screen.getByText('requested')).not.toBeNull()); + expect(requestAccess).toHaveBeenCalledTimes(1); + }); + + it('never submits when SpacetimeDB already contains the current request', async () => { + const requestAccess = vi.fn(); + const client = bridge({ + getAccessRequestStatus: vi.fn(async () => ({ + version: 1 as const, + status: 'requested' as const, + requestedAt: 1_785_414_896_000 + })), + requestAccess + }); + render(); + + await waitFor(() => expect(screen.getByText('requested')).not.toBeNull()); + const received = screen.getByRole('button', { + name: 'REQUEST RECEIVED' + }) as HTMLButtonElement; + expect(received.disabled).toBe(true); + fireEvent.click(received); + expect(requestAccess).not.toHaveBeenCalled(); + }); }); diff --git a/tests/farcasterAdmissionPanel.test.tsx b/tests/farcasterAdmissionPanel.test.tsx index 9018624..3936afa 100644 --- a/tests/farcasterAdmissionPanel.test.tsx +++ b/tests/farcasterAdmissionPanel.test.tsx @@ -96,6 +96,27 @@ describe('FarcasterAdmissionPanel', () => { expect(onCheckAgain).toHaveBeenCalledTimes(1); }); + it('renders the local request latch immediately as a disabled sent action', () => { + render( + + ); + + expect(screen.getByText('Request sent.')).not.toBeNull(); + expect(screen.getByText(/Confirming with the Hegemony records/i)).not.toBeNull(); + expect((screen.getByRole('button', { + name: 'REQUEST SENT' + }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.queryByRole('button', { name: 'CHECK AGAIN' })).toBeNull(); + }); + it('keeps a backend outage distinct from an admission rejection', () => { render( { accessRequest: { phase: 'submitting' } }); + expect(screen.getByText('Request sent.')).not.toBeNull(); + expect((screen.getByRole('button', { + name: 'REQUEST SENT' + }) as HTMLButtonElement).disabled).toBe(true); + panel.rerender( { expect(Object.keys(WARPKEEP_PATCH_NOTES_BY_VERSION)).toContain(packageJson.version); expect(getLatestPatchNotes(packageJson.version)).toMatchObject({ releasedOn: '31 JUL 2026', - title: 'THE REALM REMEMBERS' + title: 'THE PETITION STANDS' }); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /SpacetimeDB.*one exact Mark.*UTC Realm day.*no wallet.*token.*transaction.*payment/i + /Request Access.*disabled Request Sent.*first click.*without waiting/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /Private daily receipts.*retries harmless.*revoked admission.*pauses future grants/i + /already recorded by SpacetimeDB.*received.*cannot be submitted again/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).toMatch( - /SNAP burn.*wallet-attribution.*chain-scanning.*retired/i + /repeated gestures.*absorbed locally.*cycle-idempotent.*manually reviewed/i ); expect(getLatestPatchNotes(packageJson.version)?.highlights.join(' ')).not.toMatch( /released to players|deployed to players|public balances|guaranteed rewards/i ); - expect(getLatestPatchNotes(packageJson.version)?.summary).toContain('Community Mark'); + expect(getLatestPatchNotes(packageJson.version)?.summary).toContain('petition'); expect(getLatestPatchNotes(packageJson.version)?.alphaNotice).toContain('unfinished'); expect(getLatestPatchNotes(packageJson.version)?.alphaNotice).toContain( - 'cannot be spent, transferred, redeemed, or converted' + 'does not grant admission' + ); + + const alpha033 = getLatestPatchNotes('0.3.33'); + expect(alpha033).toMatchObject({ + title: 'THE REALM REMEMBERS' + }); + expect(alpha033?.highlights.join(' ')).toMatch( + /SpacetimeDB.*one exact Mark.*UTC Realm day.*no wallet.*token.*transaction.*payment/i + ); + expect(alpha033?.highlights.join(' ')).toMatch( + /Private daily receipts.*retries harmless.*revoked admission.*pauses future grants/i + ); + expect(alpha033?.highlights.join(' ')).toMatch( + /SNAP burn.*wallet-attribution.*chain-scanning.*retired/i ); const alpha0324 = getLatestPatchNotes('0.3.24'); diff --git a/tests/menuFarcasterAuthIntegration.test.tsx b/tests/menuFarcasterAuthIntegration.test.tsx index e4d9a42..3072491 100644 --- a/tests/menuFarcasterAuthIntegration.test.tsx +++ b/tests/menuFarcasterAuthIntegration.test.tsx @@ -1112,7 +1112,7 @@ describe('WarpkeepMainMenu Farcaster authentication integration', () => { 'CREDITS' ]); expect(screen.getByRole('button', { - name: 'Open patch notes for Warpkeep ALPHA 0.3.33' + name: 'Open patch notes for Warpkeep ALPHA 0.3.34' })).not.toBeNull(); expect(screen.queryByRole('button', { name: 'CONTINUE' })).toBeNull(); diff --git a/tests/menuMainMenu.test.tsx b/tests/menuMainMenu.test.tsx index 89998c1..c480b48 100644 --- a/tests/menuMainMenu.test.tsx +++ b/tests/menuMainMenu.test.tsx @@ -22,7 +22,7 @@ function installMotionPreference(matches = false) { function getPatchNotesTrigger(options: { hidden?: boolean } = {}) { return screen.getByRole('button', { ...options, - name: 'Open patch notes for Warpkeep ALPHA 0.3.33' + name: 'Open patch notes for Warpkeep ALPHA 0.3.34' }); } @@ -131,11 +131,11 @@ describe('WarpkeepMainMenu', () => { act(() => patchNotes.focus()); expect(screen.getByRole('status').textContent).toContain('living frontier'); - expect(screen.queryByRole('region', { name: 'THE REALM REMEMBERS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE PETITION STANDS' })).toBeNull(); fireEvent.click(patchNotes, { detail: 0 }); expect(screen.queryByRole('status')).toBeNull(); - expect(screen.getByRole('region', { name: 'THE REALM REMEMBERS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE PETITION STANDS' })).not.toBeNull(); expect(document.activeElement).toBe(patchNotes); }); @@ -239,25 +239,25 @@ describe('WarpkeepMainMenu', () => { expect(patchNotes.getAttribute('aria-expanded')).toBe('false'); expect(patchNotes.getAttribute('aria-controls')).toBe('warpkeep-latest-patch-notes'); - expect(screen.queryByRole('region', { name: 'THE REALM REMEMBERS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE PETITION STANDS' })).toBeNull(); act(() => patchNotes.focus()); - expect(screen.queryByRole('region', { name: 'THE REALM REMEMBERS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE PETITION STANDS' })).toBeNull(); fireEvent.click(patchNotes, { detail: 0 }); - const notes = screen.getByRole('region', { name: 'THE REALM REMEMBERS' }); + const notes = screen.getByRole('region', { name: 'THE PETITION STANDS' }); expect(patchNotes.getAttribute('aria-expanded')).toBe('true'); - expect(notes.textContent).toContain('LATEST PATCH · ALPHA 0.3.33'); - expect(notes.textContent).toContain('one exact Mark automatically'); - expect(notes.textContent).toContain('Private daily receipts'); - expect(notes.textContent).toContain('SNAP burn'); + expect(notes.textContent).toContain('LATEST PATCH · ALPHA 0.3.34'); + expect(notes.textContent).toContain('disabled Request Sent action'); + expect(notes.textContent).toContain('already recorded by SpacetimeDB'); + expect(notes.textContent).toContain('repeated gestures are absorbed locally'); expect(notes.getAttribute('tabindex')).toBe('0'); expect(within(notes).queryByRole('link')).toBeNull(); act(() => notes.focus()); expect(document.activeElement).toBe(notes); fireEvent.keyDown(document, { key: 'Escape' }); - expect(screen.queryByRole('region', { name: 'THE REALM REMEMBERS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE PETITION STANDS' })).toBeNull(); expect(document.activeElement).toBe(patchNotes); expect(onRequestReturn).not.toHaveBeenCalled(); @@ -270,19 +270,19 @@ describe('WarpkeepMainMenu', () => { const patchNotes = getPatchNotesTrigger(); fireEvent.pointerEnter(patchNotes, { pointerType: 'mouse' }); - expect(screen.getByRole('region', { name: 'THE REALM REMEMBERS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE PETITION STANDS' })).not.toBeNull(); fireEvent.pointerDown(document.body, { pointerType: 'mouse' }); - expect(screen.queryByRole('region', { name: 'THE REALM REMEMBERS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE PETITION STANDS' })).toBeNull(); fireEvent.pointerDown(patchNotes, { pointerType: 'touch' }); patchNotes.focus(); fireEvent.click(patchNotes); - expect(screen.getByRole('region', { name: 'THE REALM REMEMBERS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE PETITION STANDS' })).not.toBeNull(); fireEvent.pointerDown(patchNotes, { pointerType: 'touch' }); fireEvent.click(patchNotes); - expect(screen.queryByRole('region', { name: 'THE REALM REMEMBERS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE PETITION STANDS' })).toBeNull(); }); it('keeps hover notes reachable across the anchor gap and toggles by activation', () => { @@ -291,21 +291,21 @@ describe('WarpkeepMainMenu', () => { const patchNotes = getPatchNotesTrigger(); fireEvent.pointerEnter(patchNotes, { pointerType: 'mouse' }); - expect(screen.getByRole('region', { name: 'THE REALM REMEMBERS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE PETITION STANDS' })).not.toBeNull(); fireEvent.pointerLeave(patchNotes, { pointerType: 'mouse' }); act(() => vi.advanceTimersByTime(250)); - const panel = screen.getByRole('region', { name: 'THE REALM REMEMBERS' }); + const panel = screen.getByRole('region', { name: 'THE PETITION STANDS' }); fireEvent.pointerEnter(panel, { pointerType: 'mouse' }); act(() => vi.advanceTimersByTime(200)); - expect(screen.getByRole('region', { name: 'THE REALM REMEMBERS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE PETITION STANDS' })).not.toBeNull(); fireEvent.click(patchNotes); fireEvent.pointerLeave(patchNotes, { pointerType: 'mouse' }); act(() => vi.advanceTimersByTime(500)); - expect(screen.getByRole('region', { name: 'THE REALM REMEMBERS' })).not.toBeNull(); + expect(screen.getByRole('region', { name: 'THE PETITION STANDS' })).not.toBeNull(); fireEvent.click(patchNotes); - expect(screen.queryByRole('region', { name: 'THE REALM REMEMBERS' })).toBeNull(); + expect(screen.queryByRole('region', { name: 'THE PETITION STANDS' })).toBeNull(); }); it('keeps inactive menu controls hidden, inert, and outside the tab order', () => {