Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
787ddd2
Separate security-configuration auth failures from network failures
Aalv3 Aug 30, 2026
b2f7a40
Return to sign-in when a User API credential is authoritatively retired
Aalv3 Aug 30, 2026
a767ed5
Retire a revoked credential on canonical evidence, not on 403 alone
Aalv3 Aug 30, 2026
0565d50
Revert "Retire a revoked credential on canonical evidence, not on 403…
Aalv3 Aug 30, 2026
bf0ba81
Retire credentials only on canonical server signal
Aalv3 Aug 31, 2026
3ff24c1
Polish Floor greeting and conversation stats (#5)
Aalv3 Aug 31, 2026
5173708
Classify official Floor notices separately (#6)
Aalv3 Aug 31, 2026
fdb8314
Handle short native search queries safely (#7)
Aalv3 Sep 1, 2026
6846b9e
fix(auth): reconcile production-shipped stale-identity fix into the l…
Aalv3 Sep 3, 2026
ce66da9
fix(identity): refresh the signed-in member identity while signed in …
Aalv3 Sep 4, 2026
5b6d988
fix(avatars): authenticate private member photos and bind the media c…
Aalv3 Sep 6, 2026
f662cf3
fix(avatars): authenticate only the private member-photo route (#11)
Aalv3 Sep 6, 2026
4d0fa14
fix(ratelimit): honor Retry-After and recover avatars from transient …
Aalv3 Sep 8, 2026
0061df4
test(ratelimit): pin limiter bucket semantics for the pre-device review
Aalv3 Sep 8, 2026
6ed8fa7
fix(ratelimit): separate cooldown lifetime from per-request wait ceiling
Aalv3 Sep 8, 2026
18e812a
docs(release): record the native rate-limit resilience certification …
Aalv3 Sep 8, 2026
cb4d286
ci(detox): give the logged-out launch its real budget and retry runne…
Aalv3 Sep 9, 2026
64940a1
fix(notifications): route valid first-party destinations instead of d…
Aalv3 Sep 9, 2026
335419e
feat(notifications): bootstrap an authenticated WebView session for m…
Aalv3 Sep 9, 2026
cf588cd
feat(notifications): resolve taps to native intents instead of web se…
Aalv3 Sep 9, 2026
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
6 changes: 5 additions & 1 deletion .github/workflows/ios-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ jobs:
-quiet \
build

# --retries matches the iPad job. It covers macOS runner flake - cold
# simulator boots have repeatedly exceeded the launch budget - not
# product assertions. A test that fails every attempt still fails the
# job; retries never convert a real regression into a pass.
- name: Detox iPhone tests
run: yarn detox test --configuration ios.sim.release --cleanup --record-logs all
run: yarn detox test --configuration ios.sim.release --cleanup --record-logs all --retries 2

- name: Upload artifacts
if: failure()
Expand Down
64 changes: 64 additions & 0 deletions docs/NATIVE-NOTIFICATION-INTENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Native notification intents

A notification tap resolves to an **intent**, computed from the notification
payload before any URL is built. Four kinds exist:

| Kind | Meaning |
| --- | --- |
| `native` | an existing native screen (Topic, MemberProfile, Collection, Search, Bookmarks, Settings, Ask) |
| `badge` | `granted_badge`, presented by the native BadgeEarned screen |
| `staff_external` | staff-only `/admin`, the single documented external handoff |
| `unavailable` | one explicit bounded state for everything else |

## Why the payload, not the URL

`DiscourseUtils.endpointForSiteNotification` is lossy. A `granted_badge`
becomes `/badges/:id/basic?username=:u`, and `badge_name` is discarded. Every
earlier attempt to route badges started from that URL, which is why they all
ended at a web page: by then the information needed to build a native screen
was already gone. `notificationIntent` reads the payload first.

## Type matrix

**Native.** 1-11, 13, 14, 15, 17, 18, 20, 24, 25, 27, 28, 34, 36, 801, 802 open
Topic. 800 opens MemberProfile. 21 opens Topic when it carries one, and
otherwise resolves to `/u/:me/activity/approval-given`, which the profile
pattern already matches - a valid native destination, left as it was.

**Badge.** 12 opens BadgeEarned.

**Staff external.** 37 and 38 open `/admin` externally, for staff only.
`classifyFirstPartyMemberRoute` returns `privileged_external` only when
`isStaff` is true; a member gets `unavailable`. This is an explicit, documented
exception and does not widen external navigation for anyone else.

**Unavailable.** 16, 19, 22, 23, 26, 29, 30, 31, 32, unknown and absent types.
No silent no-op, no WebView, no external browser, no second authentication.

## BadgeEarned

Renders `badge_name` only, from the payload, with a Close action. It makes **no
network request**, so it cannot stall or fail.

`badge_title` is a boolean in Discourse - whether the badge may be worn as a
title - not descriptive text, and is never rendered. The payload carries no
badge description; showing one would require a fetch, which V1 does not do.

## What was removed, and why

A first-party WebView fallback and then an OTP-based authenticated WebView
session were both tried and abandoned. The OTP contract itself was eventually
correct - the request succeeded and the confirmation form rendered - but
Finish Login failed with "Missing, invalid or expired token", and more
importantly the architecture was wrong: a member already authenticated in the
app should never perform a second web authentication to read a notification.

Removed with it: `js/webViewSession.js`, the `first_party_web` disposition and
its path allowlist, the WebView destination-bootstrap wiring, and the
`web_session` diagnostic stages. The strict WebView navigation guard, which had
been relaxed to admit the bootstrap, is restored.

One instrumentation lesson is worth keeping: the abandoned diagnostics recorded
`destination_resume: succeeded` for a flow that had actually failed, because
the stage measured navigation mechanics rather than whether a session existed.
A success signal must observe the thing it claims to prove.
28 changes: 28 additions & 0 deletions docs/NATIVE-OTA-OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,34 @@ channel, signer key ID, rollout percentage, and the verification result.
The update must contain no credentials or private member payloads. Never print
the private key, auth tokens, or notification payloads in release evidence.

## OTA provenance tags (required)

Every OTA artifact published or promoted to production must receive an
immutable annotated git tag **at promotion time**, before any merge can rewrite
or drop the shipped commit. Squash and rebase merges both rewrite commits, so a
shipped SHA recorded in EAS routinely becomes unreachable from the trunk
minutes after it ships. Four production artifacts were lost this way before the
requirement existed.

Naming: `ota-<group-short>-<sha-short>` — the first 8 characters of the update
group UUID and of the full commit SHA.

The tag message must record:

- OTA group ID
- iOS and Android update IDs
- runtime version
- full git SHA
- rollback pointer (the group being superseded)

Tag before merging the pull request. A tag created afterwards can only be
justified by authoritative EAS records; never create one from recollection or
from inferred content equivalence, because a rewritten commit with identical
content is a different artifact for audit purposes.

Verify with `git ls-remote --tags origin | grep ota-`, and cross-check a group's
shipped SHA with `eas update:view <group> --json`.

## Recovery and kill switch

- Pause a rollout or revert it to the control update for an immediate rollout
Expand Down
7 changes: 6 additions & 1 deletion e2e/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ module.exports = {
rootDir: '..',
roots: ['<rootDir>/e2e'],
testMatch: ['<rootDir>/e2e/**/*.test.js'],
testTimeout: 120000,
// Governs hooks as well as tests. The logged-out suite's beforeEach performs
// a full device.launchApp({ delete: true }) reinstall, and on a cold macOS
// runner that has exceeded 120s outright - one observed sibling test passed
// at 119027ms, a second under the old budget. 180s accommodates the
// documented slowness without touching any element matcher.
testTimeout: 180000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
Expand Down
4 changes: 4 additions & 0 deletions e2e/loggedOutLaunch.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { by, device, element, waitFor } from 'detox';

// The element-level waits below are deliberately unchanged. The flake this
// helper is associated with was never the matcher: it was the outer Jest
// hook/test budget in e2e/jest.config.js, which a cold-boot reinstall could
// exceed before these waits had a chance to run. Keep the assertions strict.
export async function waitForLoggedOutWelcome() {
const welcome = element(by.id('logged-out-welcome-scroll'));

Expand Down
111 changes: 103 additions & 8 deletions js/Discourse.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,13 @@ import {
import NativeTopicScreen from './product/NativeTopicScreen';
import NativeCollectionScreen from './product/NativeCollectionScreen';
import NativeProfileScreen from './product/NativeProfileScreen';
import BadgeEarnedScreen from './product/BadgeEarnedScreen';
import { classifyFirstPartyMemberRoute } from './nativeMemberRouting';
import { notificationIntent } from './notificationIntent';
import {
NOTIFICATION_UNAVAILABLE,
destinationPresentation,
} from './notificationDestination';
import { consumePendingShareIntent } from './shareIntentCoordinator';
import {
loadOnboardingState,
Expand Down Expand Up @@ -391,6 +397,7 @@ class Discourse extends React.Component {
authenticated: Boolean(site?.authToken),
navigationReady,
openUrl: this.openUrl.bind(this),
openNotification: this.openNotification.bind(this),
});
if (!routed && this._pushRoute.path) {
securityEvent('push.route.deferred');
Expand Down Expand Up @@ -558,6 +565,7 @@ class Discourse extends React.Component {
navigationReady: this._navigationReady,
nativeModule: DiscourseKeyboardShortcuts,
openUrl: this.openUrl.bind(this),
openNotification: this.openNotification.bind(this),
}).finally(() => {
this._shareIntentConsumption = null;
});
Expand Down Expand Up @@ -876,6 +884,10 @@ class Discourse extends React.Component {
if (now - this._lastForegroundRefreshAt < 30000) return false;
this._lastForegroundRefreshAt = now;
const generation = ++this._foregroundRefreshGeneration;
// A server-side rename must reach the app without a logout or reinstall.
// This refreshes the active site only and reuses the guard above, so it
// cannot reintroduce the retired multi-site refresh loop.
await this._siteManager.refreshActiveIdentity().catch(() => false);
await this._siteManager.refreshNotificationState(reason).catch(() => []);
if (generation !== this._foregroundRefreshGeneration) return false;
this.setState(current => ({
Expand Down Expand Up @@ -966,20 +978,93 @@ class Discourse extends React.Component {
authenticated: Boolean(site),
isStaff: Boolean(site?.isStaff),
});
if (route.disposition === 'native') {
const presentation = destinationPresentation(route);
if (presentation.kind === 'native') {
this._siteManager.setActiveSite(site);
if (route.screen === 'Ask') {
this._navigation.navigate('HomeWrapper', { screen: 'Ask' });
} else {
this._navigation.navigate(route.screen, route.params);
}
this._navigateNative(presentation.screen, presentation.params);
return;
}
if (presentation.kind === 'external') {
Linking.openURL(presentation.url).catch(() => {});
return;
}
if (route.disposition === 'privileged_external') {
Linking.openURL(route.url).catch(() => {});
// Denied: off-origin, unauthenticated, a non-staff admin path, or an
// unrecognised destination. Nothing opens and nothing loads.
securityEvent('navigation.rejected');
}

_navigateNative(screen, params) {
if (screen === 'Ask') {
this._navigation.navigate('HomeWrapper', { screen: 'Ask' });
return;
}
this._navigation.navigate(screen, params);
}

// Notification taps resolve from the payload rather than from a URL, so a
// granted_badge keeps its badge_name and can open a native screen. Anything
// without a native destination ends in one explicit bounded state: no
// WebView, no second login, no external browser, and never a silent no-op.
openNotification(site, notification) {
const intent = notificationIntent(site, notification, {
authenticated: Boolean(site?.authToken),
isStaff: Boolean(site?.isStaff),
});
switch (intent.kind) {
case 'native':
this._siteManager.setActiveSite(site);
this._navigateNative(intent.screen, intent.params);
return;
case 'badge':
this._siteManager.setActiveSite(site);
this._navigation.navigate('BadgeEarned', { name: intent.badge.name });
return;
case 'staff_external':
// Staff-only admin handoff. notificationIntent returns this kind only
// for a staff member on a canonical /admin path.
Linking.openURL(intent.url).catch(() => {});
return;
default:
securityEvent('notification.unavailable');
Alert.alert(
NOTIFICATION_UNAVAILABLE.title,
NOTIFICATION_UNAVAILABLE.message,
[{ text: NOTIFICATION_UNAVAILABLE.close, style: 'cancel' }],
);
}
}

// A member must never be trapped behind an identity they did not choose in
// this attempt. Retire every client-side identity carrier, then start a
// normal authorization. This does not depend on the browser honouring an
// ephemeral session, and it never revokes the server-side credential of an
// account the member may still want.
async useDifferentAccount() {
if (this.state.connecting) return;
Alert.alert(
'Use a different account?',
'Adjuster Network will forget the saved sign-in on this device and ask for credentials again. Your account is not deleted.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Continue',
onPress: async () => {
this.setState({ connecting: true });
try {
await this._siteManager.resetAuthorizationIdentity();
securityEvent('auth.identity.reset');
} catch {
securityEvent('auth.identity.reset_failed');
} finally {
this.setState({ connecting: false });
}
await this.connectCanonical();
},
},
],
);
}

async connectCanonical() {
if (!adjusterNetwork.canonicalOrigin) {
Alert.alert(
Expand Down Expand Up @@ -1071,6 +1156,7 @@ class Discourse extends React.Component {
// TODO: pass only relevant props to each screen component
const screenProps = {
openUrl: this.openUrl.bind(this),
openNotification: this.openNotification.bind(this),
_handleOpenUrl: this._handleOpenUrl,
seenNotificationMap: this._seenNotificationMap,
setSeenNotificationMap: map => {
Expand Down Expand Up @@ -1160,6 +1246,7 @@ class Discourse extends React.Component {
<WelcomeScreen
busy={this.state.connecting}
onConnect={() => this.connectCanonical()}
onUseDifferentAccount={() => this.useDifferentAccount()}
/>
{this.state.privacyShield && this._blurView(theme.name)}
</ThemeContext.Provider>
Expand Down Expand Up @@ -1536,6 +1623,14 @@ class Discourse extends React.Component {
/>
)}
</Stack.Screen>
<Stack.Screen name="BadgeEarned">
{props => (
<BadgeEarnedScreen
{...props}
screenProps={{ ...screenProps }}
/>
)}
</Stack.Screen>
<Stack.Screen name="Account">
{props => (
<AccountScreen {...props} screenProps={{ ...screenProps }} />
Expand Down
Loading
Loading