Skip to content

Commit d64dd5f

Browse files
committed
Add authenticated writes example
1 parent 879837d commit d64dd5f

26 files changed

Lines changed: 10819 additions & 7 deletions

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "root",
3-
"version": "1.5.164",
3+
"version": "1.5.165",
44
"workspaces": [
55
"packages/*",
66
"packages/discovery-provider/plugins/pedalboard/apps/*",
@@ -48,6 +48,8 @@
4848
"mobile:clean": "npm run clean:auto -w @audius/mobile && npm run clean:modules",
4949
"mobile:clear-cache": "watchman watch-del-all && npm run start -w @audius/mobile -- --reset-cache",
5050
"mobile:example:trending": "cd packages/mobile/examples/trending && npx expo start",
51+
"mobile:example:auth-sign-in": "cd packages/mobile/examples/auth-sign-in && npx expo start",
52+
"mobile:example:authenticated-writes": "cd packages/mobile/examples/authenticated-writes && npx expo start",
5153
"EMBED======================================": "",
5254
"embed:prod": "npm run start:prod -w embed",
5355
"embed:stage": "npm run start:stage -w embed",

packages/common/src/services/remote-config/feature-flags.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,24 @@ export const flagDefaults: FlagDefaults = {
4747
[FeatureFlags.LAUNCHPAD_VERIFICATION]: true,
4848
[FeatureFlags.NEW_THEME_MODEL]: false
4949
}
50+
51+
/**
52+
* Minimum app version required for a flag to be enabled.
53+
* Flags with a minVersion are only enabled when appVersion >= minVersion.
54+
*/
55+
export const featureFlagMinVersions: Partial<Record<FeatureFlags, string>> = {
56+
[FeatureFlags.NEW_THEME_MODEL]: '1.5.165'
57+
}
58+
59+
/** Returns true if actual >= min (semver-style comparison) */
60+
export const isVersionAtLeast = (actual: string, min: string): boolean => {
61+
const a = actual.split('.').map((n) => parseInt(n, 10) || 0)
62+
const m = min.split('.').map((n) => parseInt(n, 10) || 0)
63+
for (let i = 0; i < Math.max(a.length, m.length); i++) {
64+
const av = a[i] ?? 0
65+
const mv = m[i] ?? 0
66+
if (av > mv) return true
67+
if (av < mv) return false
68+
}
69+
return true
70+
}

packages/common/src/services/remote-config/remote-config.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import {
1616
import {
1717
environmentFlagDefaults,
1818
FeatureFlags,
19-
flagDefaults
19+
featureFlagMinVersions,
20+
flagDefaults,
21+
isVersionAtLeast
2022
} from './feature-flags'
2123
import {
2224
IntKeys,
@@ -224,12 +226,19 @@ export const remoteConfig = <
224226

225227
/**
226228
* Gets whether a given feature flag is enabled.
227-
* Accepts a fallback flag which will be checked if the primary flag is disabled
229+
* Accepts a fallback flag which will be checked if the primary flag is disabled.
230+
* Flags with a minVersion in featureFlagMinVersions are only enabled when appVersion >= minVersion.
228231
*/
229232
function getFeatureEnabled(flag: FeatureFlags, fallbackFlag?: FeatureFlags) {
230233
const defaultVal =
231234
environmentFlagDefaults[environment][flag] ?? flagDefaults[flag]
232235

236+
// Version gate: if flag has minVersion, require appVersion >= minVersion
237+
const minVersion = featureFlagMinVersions[flag]
238+
if (minVersion && !isVersionAtLeast(appVersion, minVersion)) {
239+
return false
240+
}
241+
233242
// If the client is not ready yet, return early with `null`
234243
if (!client || !state.id) return defaultVal
235244

packages/mobile/examples/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ Environment: copy `packages/mobile/.env.dev` if needed; the app runs against sta
2121
|--------|-------------|------------------------|
2222
| [trending](./trending/) | **Expo app**: SDK setup + trending tracks (code example) | From repo root: `cd packages/mobile/examples/trending && npx expo start` or `npm run mobile:example:trending` |
2323
| [auth-sign-in](./auth-sign-in/) | **Expo app**: OAuth + bearer token (SDK). Main app: Hedgehog email/password. | OAuth example: `cd packages/mobile/examples/auth-sign-in && npx expo start` or `npm run mobile:example:auth-sign-in`. Main app: open app → sign-in. |
24+
| [authenticated-writes](./authenticated-writes/) | **Expo app + Node server**: Server holds developer app bearer; client calls endpoint to update user description (e.g. bio). No client auth. | Run server: `cd packages/mobile/examples/authenticated-writes/server && npm install && npm start`. Run client: `cd packages/mobile/examples/authenticated-writes && npx expo start`. Requires .env (see example README). |
2425

2526
## For AI / code search
2627

2728
- **SDK setup (mobile / Expo):** trending example, getSDK, sdk(appName), polyfills, Buffer process, trending tracks.
2829
- **Authentication**: sign-in, login, OAuth, bearer token, Hedgehog, identity service, `authService`, `createAuthService`, `sdk({ bearerToken })`, `oauth.login`.
30+
- **Authenticated writes**: developer app bearer on server, `updateUser`, `sdk({ apiKey, bearerToken })`, update user description.
2931

3032
Implementation lives in `packages/mobile/src` and `packages/common`; each example folder links to the exact files and entry points.
3133

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Same API key as server — so OAuth connects the user to your developer app
2+
EXPO_PUBLIC_AUDIUS_API_KEY=
3+
4+
# URL of the authenticated-writes server (run server first).
5+
# iOS simulator: http://localhost:3001
6+
# Android emulator: http://10.0.2.2:3001
7+
# Physical device (phone): use your computer's LAN IP, e.g. http://192.168.4.107:3001
8+
# (localhost = the phone itself; same WiFi required)
9+
EXPO_PUBLIC_WRITE_SERVER_URL=http://localhost:3001
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.env
2+
.env.local
3+
node_modules
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import {
3+
ActivityIndicator,
4+
StyleSheet,
5+
Text,
6+
TextInput,
7+
TouchableOpacity,
8+
View
9+
} from 'react-native'
10+
import { WebView } from 'react-native-webview'
11+
import { StatusBar } from 'expo-status-bar'
12+
import * as Linking from 'expo-linking'
13+
import { buildOAuthUrl, randomState } from './src/oauth/buildOAuthUrl'
14+
import { getSDK } from './src/sdk'
15+
import { config } from './src/config'
16+
17+
const REDIRECT_URI = 'http://localhost/oauth/callback'
18+
19+
type Screen = 'home' | 'webview' | 'signed-in'
20+
21+
export default function App() {
22+
const [screen, setScreen] = useState<Screen>('home')
23+
const [loading, setLoading] = useState(false)
24+
const [error, setError] = useState<string | null>(null)
25+
const [userId, setUserId] = useState<string | null>(null)
26+
const [profile, setProfile] = useState<{ handle: string } | null>(null)
27+
const [description, setDescription] = useState('')
28+
const [updateLoading, setUpdateLoading] = useState(false)
29+
const [result, setResult] = useState<string | null>(null)
30+
const oauthStateRef = useRef<string | null>(null)
31+
32+
const handleOpenAuth = useCallback(() => {
33+
setError(null)
34+
const state = randomState()
35+
oauthStateRef.current = state
36+
setScreen('webview')
37+
}, [])
38+
39+
const handleRedirect = useCallback(
40+
async (url: string) => {
41+
if (!url.startsWith(REDIRECT_URI) && !url.startsWith('audiuswrites://oauth/callback')) return
42+
setScreen('home')
43+
setLoading(true)
44+
setError(null)
45+
try {
46+
const parsed = Linking.parse(url)
47+
const query = (parsed.queryParams ?? {}) as Record<string, string>
48+
const token = query.token ?? query.access_token ?? (parsed.fragment ?? '').split('token=')[1]?.split('&')[0]
49+
const state = query.state
50+
if (!token) {
51+
setError('No token in redirect')
52+
return
53+
}
54+
if (state !== oauthStateRef.current) {
55+
setError('State mismatch')
56+
return
57+
}
58+
const verifyRes = await getSDK().users.verifyIDToken({ token })
59+
const data = verifyRes.data
60+
if (!data) {
61+
setError('Invalid token')
62+
return
63+
}
64+
const uid = data.userId ?? data.sub
65+
setProfile({ handle: data.handle ?? data.sub ?? 'Unknown' })
66+
setUserId(uid)
67+
setScreen('signed-in')
68+
} catch (e: unknown) {
69+
if (e && typeof e === 'object' && 'response' in e && e.response && typeof (e.response as Response).text === 'function') {
70+
const res = e.response as Response
71+
try {
72+
const body = await res.text()
73+
setError(`API error ${res.status}: ${body || res.statusText || 'Unknown'}`)
74+
} catch {
75+
setError(`API error ${res.status}`)
76+
}
77+
} else {
78+
setError(e instanceof Error ? e.message : 'Sign-in failed')
79+
}
80+
} finally {
81+
setLoading(false)
82+
}
83+
},
84+
[]
85+
)
86+
87+
useEffect(() => {
88+
const sub = Linking.addEventListener('url', (event) => handleRedirect(event.url))
89+
Linking.getInitialURL().then((url) => { if (url) handleRedirect(url) })
90+
return () => sub.remove()
91+
}, [handleRedirect])
92+
93+
const handleSignOut = useCallback(() => {
94+
setUserId(null)
95+
setProfile(null)
96+
setDescription('')
97+
setResult(null)
98+
setScreen('home')
99+
setError(null)
100+
}, [])
101+
102+
const handleUpdate = useCallback(async () => {
103+
if (!config.writeServerUrl || !userId) return
104+
setUpdateLoading(true)
105+
setResult(null)
106+
try {
107+
const res = await fetch(`${config.writeServerUrl}/update-description`, {
108+
method: 'POST',
109+
headers: { 'Content-Type': 'application/json' },
110+
body: JSON.stringify({ userId, description: description.trim() })
111+
})
112+
const data = await res.json().catch(() => ({}))
113+
if (res.ok) {
114+
setResult('Description updated.')
115+
} else {
116+
setResult(data?.error ?? `Error ${res.status}`)
117+
}
118+
} catch (e) {
119+
setResult(e instanceof Error ? e.message : 'Request failed')
120+
} finally {
121+
setUpdateLoading(false)
122+
}
123+
}, [userId, description])
124+
125+
if (screen === 'webview') {
126+
const state = oauthStateRef.current ?? randomState()
127+
oauthStateRef.current = state
128+
const oauthUrl = buildOAuthUrl({
129+
scope: 'write',
130+
redirectUri: REDIRECT_URI,
131+
state,
132+
responseMode: 'query',
133+
display: 'fullScreen',
134+
...(config.apiKey ? { apiKey: config.apiKey } : { appName: 'AudiusWritesExample' })
135+
})
136+
return (
137+
<View style={styles.container}>
138+
<TouchableOpacity style={styles.backBtn} onPress={() => setScreen('home')}>
139+
<Text style={styles.backBtnText}>← Cancel</Text>
140+
</TouchableOpacity>
141+
<WebView
142+
source={{ uri: oauthUrl }}
143+
style={styles.webview}
144+
onShouldStartLoadWithRequest={(req) => {
145+
if (req.url.startsWith(REDIRECT_URI) || req.url.startsWith('audiuswrites://oauth/callback')) {
146+
handleRedirect(req.url)
147+
return false
148+
}
149+
return true
150+
}}
151+
/>
152+
<StatusBar style="auto" />
153+
</View>
154+
)
155+
}
156+
157+
if (screen === 'signed-in' && userId && profile) {
158+
return (
159+
<View style={styles.container}>
160+
<View style={styles.card}>
161+
<View style={styles.profileRow}>
162+
<Text style={styles.handle}>@{profile.handle}</Text>
163+
<TouchableOpacity style={styles.signOutBtn} onPress={handleSignOut}>
164+
<Text style={styles.signOutBtnText}>Sign out</Text>
165+
</TouchableOpacity>
166+
</View>
167+
<Text style={styles.title}>Update description</Text>
168+
<Text style={styles.subtitle}>
169+
Server uses your stored bearer to update your bio.
170+
</Text>
171+
<TextInput
172+
style={styles.input}
173+
placeholder="New description"
174+
placeholderTextColor="#888"
175+
value={description}
176+
onChangeText={setDescription}
177+
multiline
178+
numberOfLines={3}
179+
/>
180+
<TouchableOpacity
181+
style={[styles.button, updateLoading && styles.buttonDisabled]}
182+
onPress={handleUpdate}
183+
disabled={updateLoading}
184+
>
185+
{updateLoading ? (
186+
<ActivityIndicator size="small" color="#fff" />
187+
) : (
188+
<Text style={styles.buttonText}>Update description</Text>
189+
)}
190+
</TouchableOpacity>
191+
{result ? <Text style={styles.result}>{result}</Text> : null}
192+
</View>
193+
<StatusBar style="auto" />
194+
</View>
195+
)
196+
}
197+
198+
if (!config.isConfigured) {
199+
return (
200+
<View style={styles.container}>
201+
<View style={styles.card}>
202+
<Text style={styles.title}>Authenticated writes</Text>
203+
<Text style={styles.required}>
204+
Requires your server. Create a .env with:
205+
</Text>
206+
<Text style={styles.code}>EXPO_PUBLIC_AUDIUS_API_KEY=your_api_key</Text>
207+
<Text style={styles.code}>EXPO_PUBLIC_WRITE_SERVER_URL=http://localhost:3001</Text>
208+
<Text style={styles.required}>
209+
Run the server with AUDIUS_API_KEY. See README.
210+
</Text>
211+
</View>
212+
<StatusBar style="auto" />
213+
</View>
214+
)
215+
}
216+
217+
return (
218+
<View style={styles.container}>
219+
<View style={styles.center}>
220+
<Text style={styles.title}>Authenticated writes</Text>
221+
<Text style={styles.subtitle}>
222+
Sign in with Audius (write scope) to authorize the app, then update your description.
223+
</Text>
224+
{loading ? (
225+
<ActivityIndicator size="large" style={styles.loader} />
226+
) : (
227+
<TouchableOpacity style={styles.button} onPress={handleOpenAuth} disabled={loading}>
228+
<Text style={styles.buttonText}>Sign in with Audius (write)</Text>
229+
</TouchableOpacity>
230+
)}
231+
{error ? <Text style={styles.error}>{error}</Text> : null}
232+
</View>
233+
<StatusBar style="auto" />
234+
</View>
235+
)
236+
}
237+
238+
const styles = StyleSheet.create({
239+
container: { flex: 1, backgroundColor: '#fff', paddingTop: 60 },
240+
center: { flex: 1, padding: 24, justifyContent: 'center' },
241+
card: { margin: 24, padding: 24, backgroundColor: '#f5f5f5', borderRadius: 12 },
242+
title: { fontSize: 20, fontWeight: '600', marginBottom: 8 },
243+
subtitle: { fontSize: 14, color: '#666', marginBottom: 16 },
244+
required: { fontSize: 13, color: '#333', marginTop: 12 },
245+
code: { fontFamily: 'monospace', fontSize: 12, color: '#555', marginTop: 6 },
246+
profileRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 },
247+
handle: { fontSize: 16, color: '#333' },
248+
signOutBtn: { paddingVertical: 8, paddingHorizontal: 16 },
249+
signOutBtnText: { color: '#0066cc', fontSize: 16 },
250+
input: {
251+
borderWidth: 1,
252+
borderColor: '#ccc',
253+
borderRadius: 8,
254+
padding: 12,
255+
marginBottom: 12,
256+
fontSize: 14,
257+
minHeight: 80,
258+
textAlignVertical: 'top'
259+
},
260+
button: {
261+
backgroundColor: '#CC0FE0',
262+
paddingVertical: 14,
263+
paddingHorizontal: 24,
264+
borderRadius: 8,
265+
alignSelf: 'flex-start'
266+
},
267+
buttonDisabled: { opacity: 0.7 },
268+
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
269+
result: { fontSize: 13, color: '#333', marginTop: 12 },
270+
loader: { marginVertical: 16 },
271+
error: { color: '#d32f2f', marginTop: 12, fontSize: 13 },
272+
backBtn: { padding: 16 },
273+
backBtnText: { color: '#0066cc', fontSize: 16 },
274+
webview: { flex: 1 }
275+
})

0 commit comments

Comments
 (0)